Artículo técnico

Records Selection de BIFF8 y scroll de paneles en HotXLS

Una herramienta de conciliación encuentra 3,000 celdas que discrepan del mayor y quiere que el .xls guardado abra con todas ellas seleccionadas bajo una fila de cabecera congelada. Sobre el papel eso es un record Selection. En bytes son 9 + 3,000 × 6 = 18,009, y el cuerpo de un record BIFF8 se detiene en 8,224. El mismo workbook plantea una segunda pregunta que suena trivial y no lo es: en una hoja congelada en B2, «la posición de scroll» son cuatro paneles compartiendo dos offsets de fila y dos de columna, y esos cuatro números viven en dos records distintos

Los dos problemas son aritmética de formato: la selección se corta en records que un lector tiene que volver a coser, el panel se nombra con un código que no sigue el orden de lectura, y cada eje de scroll aterriza en el record desde el que Excel lo lee

Por qué 3,000 áreas seleccionadas necesitan tres records Selection

[MS-XLS] §2.4.248 dispone el record Selection (0x001D) como una parte fija de 9 bytes seguida de las áreas. La parte fija lleva el byte de panel, rwAct y colAct de la celda activa, irefAct para el índice del área activa y cref para el recuento de áreas. Cada área es una estructura RefU con dos filas de 16 bits y dos columnas de 8 bits. El recuento más grande que cabe es (8,224 − 9) / 6 redondeado hacia abajo, lo que da 1,369 áreas y un cuerpo de 8,223 bytes, un byte por debajo del tope

HotXLS usa 1,369 como su techo por record. Cuando TXLSWorksheet guarda un grupo de selección con más áreas que ese número, escribe records Selection consecutivos para el mismo panel, 1,369 áreas cada vez, y cada record repite el mismo byte de panel, celda activa e índice activo. La selección de conciliación de 3,000 celdas sale como tres records que sostienen 1,369, 1,369 y 262 áreas. Los llamadores jamás ven ese corte; entregan un array de referencias A1

var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Diffs: TXLSSelectedAreas;
  I: Integer;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add;
    Sheet.FreezePanes(1, 1);           // la fila de cabecera y la columna A se quedan quietas

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

    // FreezePanes limpia la selección almacenada, así que seleccione tras congelar.
    // 3000 áreas se guardan como tres records 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;

El orden de las llamadas importa aquí. FreezePanes reconstruye la disposición de paneles y limpia cada selección por panel que la hoja llevara, así que una selección previa ya se habría ido a la hora de guardar. En una hoja sin congelar el panel inferior derecho todavía no existe, y la misma llamada SelectAreas devolvería False sin más. Primero congele, luego seleccione

¿A qué apunta irefAct cuando una selección abarca varios records?

En una selección partida, irefAct indexa la secuencia agregada completa de áreas a través de todos los chunks, y cada chunk lleva el mismo valor. Una selección de un área más allá del límite lo hace concreto: 1,370 áreas con la última activa se vuelven dos records, el primero con cref 1,369 y el segundo con cref 1, y ambos llevan irefAct 1,369. En el segundo record ese índice es mayor que el recuento de áreas del propio record. Parece roto si usted lee un record cada vez, y hay dos clases de lector que tropiezan con ello

  • Un lector que comprueba irefAct contra cref dentro de cada record rechaza un archivo perfectamente válido en el segundo chunk
  • Un lector que sustituye su estado de selección en cada record Selection conserva solo el último chunk y pierde las primeras 1,369 áreas

El lector de HotXLS añade los records consecutivos del mismo panel a un solo grupo y exige que cada chunk concuerde en la fila activa, la columna activa y irefAct. La comprobación de rango de irefAct, y la de que la celda activa se sienta de verdad dentro del área al que apunta, corren solo cuando llega el record EOF de la hoja y la secuencia completa es conocida. Comprobar antes sería código más sencillo y la respuesta equivocada para cualquier selección de más de 1,369 áreas

Por qué HotXLS escribe una selección de hoja grande como varios records Selection de BIFF8: el tope de cuerpo de 8.224 bytes admite 9 bytes fijos más 1369 áreas RefU de seis bytes, así que 3.000 áreas se convierten en tres records del mismo panel de 1369, 1369 y 262, y irefAct indexa la secuencia agregada, de modo que 1370 áreas con la última activa dejan ambos records con irefAct 1369
Cada bloque repite la misma celda activa e índice, el lector de HotXLS anexa los records consecutivos del mismo panel en un solo grupo, y la comprobación de rango se ejecuta solo en el record EOF, cuando la secuencia completa ya se conoce

Por el lado de escritura, la sobrecarga de SelectAreas con el panel primero no tiene techo propio de 1,369 áreas. Parsea cada referencia antes de tocar la hoja: los marcadores $ se aceptan y se quitan, un rango invertido como C9:A2 se normaliza a A2:C9, y una entrada vacía o imparsable falla la llamada entera. El índice activo tiene que caer dentro del array y quedarse en 32,767 o por debajo, porque irefAct es un campo con signo de 16 bits. Cualquier fallo devuelve False y deja la selección previa exactamente como estaba

Los códigos de panel no siguen el orden de lectura

Un record Selection nombra su panel con el código numérico que define el formato: 0 para el inferior derecho, 1 para el superior derecho, 2 para el inferior izquierdo y 3 para el superior izquierdo. La enumeración pública TXLSPanePosition está declarada en orden de lectura, xlspTopLeft, xlspTopRight, xlspBottomLeft, xlspBottomRight. Así que Ord(xlspTopLeft) es 0, y 0 en el archivo significa inferior derecho. Un escritor que haga un cast de la enumeración directo al byte de panel pone cada selección del panel superior izquierdo en el panel inferior derecho, y el archivo sigue siendo estructuralmente válido porque ambos son códigos de panel legales

Cada punto de entrada consciente de paneles en HotXLS convierte la enumeración a través de un case explícito, así que el código de aplicación nunca maneja los códigos numéricos. La existencia del panel se comprueba además contra la geometría actual de congelación o división. El panel superior izquierdo existe siempre. El superior derecho necesita una división vertical, el inferior izquierdo una horizontal, y el inferior derecho ambas. Para un panel que la hoja no tiene, SelectAreas devuelve False y GetSelectedAreas devuelve un array vacío con ActiveAreaIndex puesto a -1, y ninguna de las dos llamadas crea un panel, una selección o una celda por el camino

Cómo mapea HotXLS TXLSPanePosition al byte de panel de Selection en BIFF8: el enum está declarado en orden de lectura, así que Ord(xlspTopLeft) es 0, mientras el archivo define 0 para bottom-right, 1 para top-right, 2 para bottom-left y 3 para top-left, de modo que cada punto de entrada consciente de paneles convierte mediante un case explícito
Fundir el enum directamente al byte de panel escribiría cada selección top-left sobre el panel bottom-right, así que HotXLS comprueba además la existencia del panel contra la geometría actual de división o inmovilización antes de escribir

La hoja también conserva el SelectAreas(Areas, ActiveAreaIndex) antiguo de dos argumentos, que escribe en el panel que esté activo en ese momento y devuelve un estado Integer. En una hoja con paneles congelados eso suele ser una apuesta sobre un estado que usted mismo no puso. Para cualquier cosa que corra sobre hojas congeladas o divididas, use primero la sobrecarga que recibe un TXLSPanePosition; el panel pasa a ser parte de la llamada, y un resultado False le dice que la geometría y la petición discrepan. La lectura de vuelta sigue la misma 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;

¿Dónde vive la posición de scroll de cada panel?

La posición de scroll de cada panel se reparte entre dos records, porque cuatro paneles comparten solo dos posiciones de fila y dos de columna. En un workbook clásico, la primera fila visible de los paneles superiores y la primera columna visible de los paneles izquierdos son Window2.rwTop y Window2.colLeft. La fila de los paneles inferiores y la columna de los paneles derechos son Pane.rwTop y Pane.colLeft. El superior derecho toma por tanto su fila de Window2 y su columna de Pane, y el inferior izquierdo al revés

ScrollWindow y TryGetWindowScroll siguen esa disposición exactamente y usan números de fila y columna 1-based. ScrollWindow(xlspTopRight, R, C) escribe Window2.rwTop y Pane.colLeft, lo que significa que fijar la columna del superior derecho también mueve el panel inferior derecho. Eso coincide con Excel, donde los dos paneles derechos comparten una única barra de scroll horizontal. Un panel ausente hace que ScrollWindow devuelva False y que TryGetWindowScroll ponga ambas salidas a cero. Una fila o columna fuera de rango se rechaza antes de que cambie cualquiera de los dos ejes, así que una columna mala no puede dejar una fila aplicada a medias

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

  // Bottom-right: eje de fila inferior (Pane.rwTop) y eje de columna derecha (Pane.colLeft)
  Sheet.ScrollWindow(xlspBottomRight, 500, 3);

  // Top-right comparte el eje de columna derecha, así que esto también mueve bottom-right a la columna 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]));
    // Bottom-right empieza en la fila 500, columna 6
end;
Dónde vive cada eje de scroll de panel en HotXLS: cuatro paneles comparten dos posiciones de fila y dos de columna, así que la fila superior y la columna izquierda son Window2.rwTop y Window2.colLeft mientras la fila inferior y la columna derecha son Pane.rwTop y Pane.colLeft, y ScrollWindow(xlspTopRight, 1, 6) escribe un campo Window2 más un campo Pane para que bottom-right siga
XLSX reparte estos mismos datos entre los atributos sheetView y pane topLeftCell, y colapsar las dos capas en una es exactamente cómo una posición de scroll superior o izquierda desaparece en silencio al cargar

XLSX guarda las mismas dos capas en dos elementos: sheetView/@topLeftCell (ECMA-376 Part 1, §18.3.1.87) para la ventana en su conjunto y el hijo pane/@topLeftCell (§18.3.1.66) para el lado inferior derecho de una división. Ambos atributos pueden estar presentes a la vez. Un loader que se queda solo con uno de ellos pierde o las filas superiores o las columnas izquierdas de cada hoja congelada que toque. TXLSXWorksheet lee el atributo exterior en los campos de nivel de ventana primero, deja que el hijo pane sobrescriba solo los campos de nivel de panel, y escribe ambos de vuelta por separado. ScrollWindow y TryGetWindowScroll tienen las mismas firmas y el mismo significado en TXLSXWorksheet, y copiar una hoja lleva ambas capas en cualquiera de los dos formatos

Las propiedades más estrechas siguen ahí para el código que solo necesita un par de ejes: ScrollRow y ScrollColumn en la hoja clásica, y el SetPaneScroll y GetPaneScroll zero-based para el panel inferior derecho en XLSX. Un control de renderizado mantiene su propio TopRow y LeftCol, como describe el artículo sobre renderizar workbooks en una rejilla VCL personalizada, y ese estado de ejecución está separado de lo que se guarda. La geometría de congelación y división en sí se fija con los ajustes a nivel de hoja cubiertos en protección de hoja, configuración de página e impresión

Los records Selection corruptos se conservan byte a byte

Cuando un record Selection está malformado, HotXLS conserva su cuerpo como bytes opacos, reporta el código de diagnóstico 1304 (xlsDiagnosticSelectionRecordInvalid) como advertencia y vuelve a escribir el cuerpo original sin cambios al guardar. Archivos de exportadores desconocidos o de escritores BIFF hechos a mano pueden contener records así. Antes de que un record entre en el grupo de su panel, el lector comprueba un byte de panel de 3 o menos, records contiguos por panel, un cref entre 1 y 1,369 con un cuerpo de exactamente 9 + cref × 6 bytes, chunks que concuerdan en la celda activa y un irefAct sin su bit de signo, una columna activa sobre la rejilla, y ninguna área invertida. Los problemas dentro de un solo record físico se reportan una vez por record; las contradicciones que solo aparecen tras la agregación, como un irefAct apuntando más allá del recuento total de áreas o una celda activa fuera del área indexada, se reportan una vez por grupo en el EOF

Un grupo inválido se queda fuera de la API tipada: GetSelectedAreas devuelve un array vacío con índice -1 para ese panel, y los demás paneles siguen funcionando con normalidad

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 es su propio TStrings; HotXLS solo llena Diagnostics
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

Reparar el record, acotando irefAct o soltando el área invertida, era la otra opción. Conservarlo es el default más seguro: una selección reparada es una que nadie eligió, y los bytes originales se habrían perdido. El mensaje de diagnóstico nombra la comprobación que falló, como «el recuento de áreas no coincide con la longitud del record» o «los records del mismo panel no son contiguos», y RecordId es siempre $001D para este código

Las inserciones de filas y columnas remapean la selección de cada panel

Insertar o borrar filas o columnas enteras remapea la selección de todos los paneles a través de una única rutina compartida, tanto en el motor clásico como en el XLSX. Las áreas supervivientes conservan su orden y se desplazan con las celdas que cubren, y el área activa conserva su identidad. Si el área activa se borra, la primera área superviviente detrás de ella pasa a activa; si no hay nada detrás, lo hace la última superviviente delante. Si se borran todas, el grupo colapsa a una sola celda en el límite del borrado. Una celda activa que ya no cae dentro del área elegida se mueve a la esquina superior izquierda de esa área, así que el índice y la coordenada jamás pueden contradecirse en el archivo guardado

Los grupos marcados como inválidos en la carga los deja en paz el remapeador, así que sus bytes originales siguen haciendo la ida y vuelta después de la edición. Cambiar la selección de un panel reescribe solo los records de ese panel y deja los records de los otros paneles byte a byte idénticos. Los archivos ODS no llevan estado de selección por panel alguno, porque OpenDocument no tiene estructura equivalente de vista de hoja por panel; las selecciones y las posiciones de scroll de paneles aplican a XLS y XLSX

Si su aplicación guarda workbooks que la gente abre para revisar celdas marcadas, continuar donde lo dejaron o leer un dashboard congelado, las llamadas de selección y scroll conscientes de paneles vienen con el componente de hojas de cálculo HotXLS para Delphi y se comportan igual para salida .xls y .xlsx