Articolo tecnico

Record Selection BIFF8 e scroll dei riquadri con HotXLS

Uno strumento di riconciliazione trova 3.000 celle in disaccordo con il ledger e vuole che il .xls salvato si apra con tutte quante selezionate sotto una riga di intestazione bloccata. Sulla carta è un record Selection. In byte sono 9 + 3.000 × 6 = 18.009, e un corpo di record BIFF8 si ferma a 8.224. Lo stesso workbook solleva una seconda domanda che sembra banale e non lo è: su un foglio bloccato a B2, "la posizione di scroll" sono quattro riquadri che condividono due offset di riga e due offset di colonna, e quei quattro numeri stanno in due record diversi

Entrambi i problemi sono aritmetica di formato: la selezione viene tagliata in record che un lettore deve ricucire insieme, il riquadro è nominato da un codice che non segue l'ordine di lettura, e ogni asse di scroll atterra nel record da cui Excel lo legge

Perché 3.000 aree selezionate richiedono tre record Selection

[MS-XLS] §2.4.248 compone il record Selection (0x001D) come una parte fissa di 9 byte seguita dalle aree. La parte fissa tiene il byte del riquadro, rwAct e colAct per la cella attiva, irefAct per l'indice dell'area attiva, e cref per il conteggio delle aree. Ogni area è una struttura RefU con due righe a 16 bit e due colonne a 8 bit. Il conteggio massimo che ci sta è (8.224 − 9) / 6 arrotondato per difetto, che dà 1.369 aree e un corpo di 8.223 byte, un byte sotto il tetto

HotXLS usa 1.369 come proprio tetto per record. Quando TXLSWorksheet salva un gruppo di selezione con più aree di così, scrive record Selection consecutivi per lo stesso riquadro, 1.369 aree alla volta, e ogni record ripete lo stesso byte di riquadro, cella attiva e indice attivo. La selezione di riconciliazione da 3.000 celle esce come tre record che tengono 1.369, 1.369 e 262 aree. I chiamanti non vedono mai quello split; consegnano un array di riferimenti A1

var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Diffs: TXLSSelectedAreas;
  I: Integer;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add;
    Sheet.FreezePanes(1, 1);           // riga di intestazione e colonna A restano ferme

    SetLength(Diffs, 3000);
    for I := 0 to High(Diffs) do
      Diffs[I] := Format('C%d', [I + 2]);

    // FreezePanes pulisce la selezione conservata, quindi selezionate dopo il freeze.
    // 3000 aree vengono salvate come tre record Selection: 1369 + 1369 + 262
    if not Sheet.SelectAreas(xlspBottomRight, Diffs, 0) then
      raise Exception.Create('Selection rejected');

    Book.SaveAs('reconciliation.xls');
  finally
    Book.Free;
  end;
end;

L'ordine delle chiamate conta qui. FreezePanes ricostruisce il layout dei riquadri e pulisce ogni selezione per riquadro che il foglio stava portando, quindi una selezione precedente se ne sarebbe andata al momento del salvataggio. Su un foglio non bloccato il riquadro in basso a destra non esiste ancora, e la stessa chiamata SelectAreas restituirebbe semplicemente False. Prima il freeze, poi la selezione

A cosa punta irefAct quando una selezione copre più record?

In una selezione divisa, irefAct indicizza l'intera sequenza aggregata di aree attraverso tutti i chunk, e ogni chunk porta lo stesso valore. Una selezione di un'area oltre il limite rende tutto concreto: 1.370 aree con l'ultima attiva diventano due record, il primo con cref 1.369 e il secondo con cref 1, e entrambi portano irefAct 1.369. Nel secondo record quell'indice è più grande del conteggio aree del record stesso. Sembra rotto se leggete un record alla volta, e due tipi di lettore ci inciampano

  • Un lettore che controlla irefAct contro cref dentro ogni record respinge un file perfettamente valido al secondo chunk
  • Un lettore che sostituisce il proprio stato di selezione a ogni record Selection conserva solo l'ultimo chunk e butta via le prime 1.369 aree

Il lettore di HotXLS accoda i record consecutivi per lo stesso riquadro in un gruppo ed esige che ogni chunk concordi su riga attiva, colonna attiva e irefAct. Il controllo di intervallo su irefAct, e il controllo che la cella attiva stia davvero dentro l'area a cui punta, girano solo quando arriva il record EOF del worksheet e la sequenza completa è nota. Controllare prima sarebbe codice più semplice e la risposta sbagliata per qualsiasi selezione oltre 1.369 aree

Perché HotXLS scrive una selezione di worksheet grande come diversi record Selection BIFF8: il tetto del corpo di 8.224 byte sta in 9 byte fissi più 1369 aree RefU da sei byte, così 3.000 aree diventano tre record dello stesso riquadro da 1369, 1369 e 262, e irefAct indicizza la sequenza aggregata così 1370 aree con l'ultima attiva danno a entrambi i record irefAct 1369
Ogni chunk ripete la stessa cella attiva e lo stesso indice, il lettore HotXLS accoda i record consecutivi dello stesso riquadro in un gruppo, e il controllo di intervallo gira solo al record EOF una volta nota la sequenza completa

Sul lato scrittura, l'overload di SelectAreas con il riquadro prima non ha un tetto proprio di 1.369 aree. Parsa ogni riferimento prima di toccare il foglio: i marcatori $ sono accettati e tolti, un intervallo invertito come C9:A2 viene normalizzato in A2:C9, e una voce vuota o non parsabile fa fallire l'intera chiamata. L'indice attivo deve cadere dentro l'array e restare a 32.767 o sotto, perché irefAct è un campo a 16 bit con segno. Qualsiasi fallimento restituisce False e lascia la selezione precedente esattamente com'era

I codici dei riquadri non seguono l'ordine di lettura

Un record Selection nomina il proprio riquadro con il codice numerico che il formato definisce: 0 per in basso a destra, 1 per in alto a destra, 2 per in basso a sinistra e 3 per in alto a sinistra. L'enumerazione pubblica TXLSPanePosition è dichiarata in ordine di lettura, xlspTopLeft, xlspTopRight, xlspBottomLeft, xlspBottomRight. Quindi Ord(xlspTopLeft) è 0, e 0 nel file significa in basso a destra. Uno scrittore che fa il cast dell'enumerazione dritto nel byte del riquadro mette ogni selezione in alto a sinistra sul riquadro in basso a destra, e il file resta strutturalmente valido perché entrambi sono codici di riquadro legali

Ogni punto di ingresso consapevole dei riquadri in HotXLS converte l'enumerazione attraverso uno statement case esplicito, così il codice applicativo non maneggia mai i codici numerici. L'esistenza del riquadro viene controllata anche contro la geometria corrente di freeze o split. Il riquadro in alto a sinistra esiste sempre. Quello in alto a destra richiede uno split verticale, quello in basso a sinistra uno orizzontale, e quello in basso a destra entrambi. Per un riquadro che il foglio non ha, SelectAreas restituisce False e GetSelectedAreas restituisce un array vuoto con ActiveAreaIndex a -1, e nessuna delle due chiamate crea nel frattempo un riquadro, una selezione o una cella

Come HotXLS mappa TXLSPanePosition sul byte di riquadro del record Selection BIFF8: l'enumerazione è dichiarata in ordine di lettura quindi Ord(xlspTopLeft) è 0, mentre il file definisce 0 per in basso a destra, 1 per in alto a destra, 2 per in basso a sinistra e 3 per in alto a sinistra, così ogni punto di ingresso consapevole dei riquadri converte attraverso uno statement case esplicito
Fare il cast dell'enumerazione dritto nel byte del riquadro scriverebbe ogni selezione in alto a sinistra sul riquadro in basso a destra, quindi HotXLS controlla anche l'esistenza del riquadro contro la geometria corrente di split o freeze prima di scrivere

Il worksheet conserva anche la SelectAreas(Areas, ActiveAreaIndex) a due argomenti più vecchia, che scrive sul riquadro correntemente attivo e restituisce uno stato Integer. Su un foglio con riquadri bloccati è spesso un'ipotesi su stato che non avete impostato voi. Usate per primo l'overload che prende un TXLSPanePosition per qualsiasi cosa giri su fogli bloccati o divisi; il riquadro allora fa parte della chiamata, e un risultato False vi dice che geometria e richiesta sono in disaccordo. La rilettura del risultato segue la stessa forma:

var
  Areas: TXLSSelectedAreas;
  Active: Integer;
begin
  Areas := Sheet.GetSelectedAreas(xlspBottomRight, Active);
  if Active < 0 then
    Memo1.Lines.Add('No usable selection on the bottom-right pane')
  else
    Memo1.Lines.Add(Format('%d areas, active area %s',
      [Length(Areas), Areas[Active]]));
end;

Dove vive la posizione di scroll di ogni riquadro?

La posizione di scroll di ogni riquadro è divisa su due record, perché quattro riquadri condividono solo due posizioni di riga e due di colonna. In un workbook classico la prima riga visibile dei riquadri superiori e la prima colonna visibile dei riquadri a sinistra sono Window2.rwTop e Window2.colLeft. La riga dei riquadri inferiori e la colonna dei riquadri a destra sono Pane.rwTop e Pane.colLeft. Quello in alto a destra prende quindi la riga da Window2 e la colonna da Pane, e quello in basso a sinistra fa il contrario

ScrollWindow e TryGetWindowScroll seguono quel layout esattamente e usano numeri di riga e colonna a base 1. ScrollWindow(xlspTopRight, R, C) scrive Window2.rwTop e Pane.colLeft, il che significa che impostare la colonna del riquadro in alto a destra muove anche quello in basso a destra. Combacia con Excel, dove i due riquadri a destra condividono una sola barra di scorrimento orizzontale. Un riquadro mancante fa restituire a ScrollWindow False e fa impostare a TryGetWindowScroll entrambi gli output a zero. Una riga o colonna fuori intervallo viene respinta prima che uno dei due assi cambi, quindi una colonna sbagliata non può lasciare dietro una riga applicata a metà

var
  Row, Col: Integer;
begin
  Sheet.FreezePanes(1, 1);

  // In basso a destra: asse riga inferiore (Pane.rwTop) e asse colonna destra (Pane.colLeft)
  Sheet.ScrollWindow(xlspBottomRight, 500, 3);

  // In alto a destra condivide l'asse colonna destra, quindi questo porta anche in basso a destra alla colonna 6
  Sheet.ScrollWindow(xlspTopRight, 1, 6);

  if Sheet.TryGetWindowScroll(xlspBottomRight, Row, Col) then
    Memo1.Lines.Add(Format('Bottom-right starts at row %d, column %d', [Row, Col]));
    // In basso a destra parte dalla riga 500, colonna 6
end;
Dove vive ogni asse di scroll dei riquadri in HotXLS: quattro riquadri condividono due posizioni di riga e due di colonna, quindi la riga superiore e la colonna sinistra sono Window2.rwTop e Window2.colLeft mentre la riga inferiore e la colonna destra sono Pane.rwTop e Pane.colLeft, e ScrollWindow(xlspTopRight, 1, 6) scrive un campo Window2 più un campo Pane così quello in basso a destra segue
XLSX distribuisce gli stessi dati sugli attributi topLeftCell di sheetView e pane, e collassare i due layer in uno è esattamente il modo in cui una posizione di scroll superiore o sinistra scompare in silenzio al caricamento

XLSX conserva gli stessi due layer in due elementi: sheetView/@topLeftCell (ECMA-376 Parte 1, §18.3.1.87) per la finestra nel suo complesso e il figlio pane/@topLeftCell (§18.3.1.66) per il lato in basso a destra di uno split. Entrambi gli attributi possono essere presenti insieme. Un loader che ne conserva solo uno perde o le righe superiori o le colonne sinistre di ogni foglio bloccato che tocca. TXLSXWorksheet legge l'attributo esterno nei campi a livello finestra per primo, lascia che il figlio pane faccia override dei soli campi a livello riquadro, e riscrive entrambi separatamente. ScrollWindow e TryGetWindowScroll hanno le stesse firme e lo stesso significato su TXLSXWorksheet, e copiare un worksheet porta entrambi i layer in entrambi i formati

Le proprietà più strette restano lì per il codice che richiede solo una coppia di assi: ScrollRow e ScrollColumn sul worksheet classico, e la SetPaneScroll e GetPaneScroll a base zero per il riquadro in basso a destra su XLSX. Un controllo di rendering tiene i propri TopRow e LeftCol, come descrive l'articolo sul rendering dei workbook in una griglia VCL personalizzata, e quello stato di runtime è separato da ciò che viene salvato. La geometria di freeze e split in sé si imposta con le impostazioni a livello foglio coperte in protezione del foglio, setup pagina e stampa

I record Selection corrotti vengono conservati byte per byte

Quando un record Selection è malformato, HotXLS conserva il suo corpo come byte opachi, riporta il codice diagnostico 1304 (xlsDiagnosticSelectionRecordInvalid) come avviso, e riscrive il corpo originale immutato al salvataggio. File da esportatori sconosciuti o scrittori BIFF artigianali possono contenere record simili. Prima che un record entri nel gruppo del proprio riquadro, il lettore controlla un byte di riquadro di 3 o meno, record contigui per riquadro, cref tra 1 e 1.369 con un corpo di esattamente 9 + cref × 6 byte, chunk concordi su cella attiva e un irefAct senza il bit di segno, una colonna attiva sulla griglia, e nessuna area invertita. I problemi dentro un singolo record fisico vengono riportati una volta per record; le contraddizioni che emergono solo dopo l'aggregazione, come un irefAct che punta oltre il conteggio totale di aree o una cella attiva fuori dall'area indicizzata, vengono riportate una volta per gruppo all'EOF

Un gruppo invalido resta fuori dall'API tipizzata: GetSelectedAreas restituisce un array vuoto con indice -1 per quel riquadro, e gli altri riquadri continuano a funzionare normalmente

var
  I: Integer;
  D: TXLSDiagnostic;
begin
  if Book.Open('supplier-upload.xls') <> 1 then
    Exit;
  for I := 0 to Book.Diagnostics.Count - 1 do
  begin
    D := Book.Diagnostics[I];
    if D.Code = xlsDiagnosticSelectionRecordInvalid then
      // Log è il vostro TStrings; HotXLS riempie solo Diagnostics
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

Riparare il record, serrando irefAct o buttando l'area invertita, era l'altra opzione. Conservarlo è il default più sicuro: una selezione riparata è una che nessuno ha scelto, e i byte originali sarebbero andati persi. Il messaggio diagnostico nomina il controllo fallito, come "il conteggio aree non combacia con la lunghezza del record" o "i record per lo stesso riquadro non sono contigui", e RecordId è sempre $001D per questo codice

Gli inserimenti di righe e colonne rimappano la selezione di ogni riquadro

Inserire o eliminare righe o colonne intere rimappa la selezione di ogni riquadro attraverso una sola routine condivisa, sia nel motore classico sia in quello XLSX. Le aree sopravvissute conservano il proprio ordine e si spostano con le celle che coprono, e l'area attiva conserva la propria identità. Se l'area attiva è eliminata, la prima area sopravvissuta dopo di lei diventa attiva; se non c'è nulla dopo, lo fa l'ultima sopravvissuta prima. Se ogni area è eliminata, il gruppo collassa a una singola cella al confine dell'eliminazione. Una cella attiva che non cade più dentro l'area scelta si sposta all'angolo in alto a sinistra di quell'area, così indice e coordinata non possono mai contraddirsi nel file salvato

I gruppi marcati invalidi al caricamento vengono lasciati in pace dal remapper, così i loro byte originali fanno comunque il round trip dopo la modifica. Cambiare la selezione su un riquadro riscrive solo i record di quel riquadro e lascia i record degli altri riquadri identici byte per byte. I file ODS non portano alcuno stato di selezione per riquadro, perché OpenDocument non ha una struttura equivalente di vista del worksheet per riquadro; selezioni e posizioni di scroll dei riquadri valgono per XLS e XLSX

Se la vostra applicazione salva workbook che le persone aprono per rivedere celle segnalate, riprendere da dove avevano lasciato, o leggere una dashboard bloccata, le chiamate di selezione e scroll consapevoli dei riquadri viaggiano con l'HotXLS Delphi spreadsheet component e si comportano allo stesso modo per output .xls e .xlsx