Artigo Técnico

Registros Selection do BIFF8 e pane scroll no Delphi

Uma ferramenta de conciliação acha 3.000 células que discordam do livro contábil e quer que o .xls salvo abra com todas elas selecionadas sob uma linha de cabeçalho congelada. No papel isso é um registro Selection. Em bytes são 9 + 3.000 × 6 = 18.009, e o corpo de um registro BIFF8 para em 8.224. A mesma pasta de trabalho levanta uma segunda pergunta que soa trivial e não é: numa planilha congelada em B2, "a posição de scroll" são quatro panes compartilhando dois offsets de linha e dois de coluna, e esses quatro números vivem em dois registros diferentes

Os dois problemas são aritmética de formato: a seleção é cortada em registros que um leitor precisa costurar de volta, o pane é nomeado por um código que não segue a ordem de leitura, e cada eixo de scroll aterrissa no registro de que o Excel o lê

Por que 3.000 áreas selecionadas precisam de três registros Selection

A [MS-XLS] §2.4.248 desenha o registro Selection (0x001D) como uma parte fixa de 9 bytes seguida pelas áreas. A parte fixa guarda o byte de pane, o rwAct e o colAct da célula ativa, o irefAct do índice da área ativa, e o cref da contagem de áreas. Cada área é uma estrutura RefU com duas linhas de 16 bits e duas colunas de 8 bits. A maior contagem que cabe é (8.224 − 9) / 6 arredondada para baixo, o que dá 1.369 áreas e um corpo de 8.223 bytes, um byte abaixo do teto

O HotXLS usa 1.369 como teto por registro. Quando o TXLSWorksheet salva um grupo de seleção com mais áreas que isso, ele grava registros Selection consecutivos do mesmo pane, 1.369 áreas por vez, e cada registro repete o mesmo byte de pane, célula ativa e índice ativo. A seleção de conciliação de 3.000 células sai como três registros segurando 1.369, 1.369 e 262 áreas. Quem chama nunca vê essa divisão; entrega um array de referências A1

var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Diffs: TXLSSelectedAreas;
  I: Integer;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add;
    Sheet.FreezePanes(1, 1);           // a linha de cabeçalho e a coluna A ficam paradas

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

    // FreezePanes limpa a seleção armazenada, então selecione depois de congelar.
    // 3000 áreas são salvas como três registros 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;

A ordem das chamadas importa aqui. O FreezePanes reconstrói o layout de panes e limpa toda seleção por pane que a planilha carregava, então uma seleção anterior teria ido embora na hora de salvar. Numa planilha sem congelamento o pane inferior-direito ainda não existe, e a mesma chamada SelectAreas simplesmente retornaria False. Congele, depois selecione

Para que aponta o irefAct quando uma seleção atravessa vários registros?

Numa seleção dividida, o irefAct indexa a sequência agregada inteira de áreas através de todos os blocos, e todo bloco carrega o mesmo valor. Uma seleção uma área além do limite torna isso concreto: 1.370 áreas com a última ativa viram dois registros, o primeiro com cref 1.369 e o segundo com cref 1, e ambos carregam irefAct 1.369. No segundo registro esse índice é maior que a contagem de áreas do próprio registro. Isso parece quebrado se você lê um registro por vez, e dois tipos de leitor tropeçam nisso

  • Um leitor que confere o irefAct contra o cref dentro de cada registro rejeita um arquivo perfeitamente válido no segundo bloco
  • Um leitor que substitui o estado de seleção dele a cada registro Selection guarda só o último bloco e descarta as primeiras 1.369 áreas

O leitor do HotXLS anexa registros consecutivos do mesmo pane num só grupo e exige que todo bloco concorde na linha ativa, na coluna ativa e no irefAct. A checagem de intervalo do irefAct, e a checagem de que a célula ativa realmente senta dentro da área para a qual aponta, rodam só quando o registro EOF da planilha chega e a sequência completa é conhecida. Checar antes seria código mais simples e a resposta errada para qualquer seleção acima de 1.369 áreas

Por que o HotXLS grava uma seleção grande de planilha como vários registros Selection do BIFF8: o teto de corpo de 8.224 bytes cabe em 9 bytes fixos mais 1369 áreas RefU de seis bytes, então 3.000 áreas viram três registros do mesmo pane de 1369, 1369 e 262, e o irefAct indexa a sequência agregada, então 1370 áreas com a última ativa dão a ambos os registros irefAct 1369
Cada bloco repete a mesma célula e índice ativos, o reader do HotXLS anexa registros consecutivos do mesmo pane num só grupo, e a checagem de intervalo roda só no registro EOF quando a sequência completa já é conhecida

No lado da escrita, a sobrecarga do SelectAreas que recebe o pane primeiro não tem teto de 1.369 áreas próprio. Ela analisa toda referência antes de tocar na planilha: marcadores $ são aceitos e removidos, um intervalo invertido como C9:A2 é normalizado para A2:C9, e uma entrada vazia ou não analisável falha a chamada inteira. O índice ativo precisa cair dentro do array e ficar em ou abaixo de 32.767, porque o irefAct é um campo de 16 bits com sinal. Qualquer falha retorna False e deixa a seleção anterior exatamente como estava

Códigos de pane não seguem a ordem de leitura

Um registro Selection nomeia o pane dele com o código numérico que o formato define: 0 para bottom-right, 1 para top-right, 2 para bottom-left e 3 para top-left. A enumeração pública TXLSPanePosition é declarada em ordem de leitura, xlspTopLeft, xlspTopRight, xlspBottomLeft, xlspBottomRight. Então Ord(xlspTopLeft) é 0, e 0 no arquivo significa bottom-right. Um escritor que faz cast do enum direto no byte de pane põe toda seleção top-left no pane bottom-right, e o arquivo continua estruturalmente válido porque os dois são códigos de pane legais

Todo ponto de entrada consciente de pane no HotXLS converte o enum por um case explícito, então o código da aplicação nunca lida com os códigos numéricos. A existência do pane também é checada contra a geometria atual de freeze ou split. O pane top-left sempre existe. O top-right precisa de um split vertical, o bottom-left de um horizontal, e o bottom-right de ambos. Para um pane que a planilha não tem, o SelectAreas retorna False e o GetSelectedAreas retorna um array vazio com ActiveAreaIndex em -1, e nenhuma das chamadas cria pane, seleção ou célula no caminho

Como o HotXLS mapeia TXLSPanePosition no byte de pane do Selection do BIFF8: o enum é declarado em ordem de leitura, então Ord(xlspTopLeft) é 0, enquanto o arquivo define 0 para bottom-right, 1 para top-right, 2 para bottom-left e 3 para top-left, então todo ponto de entrada consciente de pane converte por um case explícito
Fazer cast do enum direto no byte de pane gravaria toda seleção top-left no pane bottom-right, então o HotXLS também checa a existência do pane contra a geometria atual de split ou freeze antes de gravar

A planilha também mantém o SelectAreas(Areas, ActiveAreaIndex) mais antigo de dois argumentos, que grava no pane que estiver ativo no momento e retorna um status Integer. Numa planilha com panes congelados isso costuma ser um chute sobre um estado que você mesmo não definiu. Use a sobrecarga que recebe um TXLSPanePosition primeiro para qualquer coisa que rode em planilhas congeladas ou divididas; o pane então faz parte da chamada, e um resultado False diz que a geometria e o pedido discordam. A leitura de volta segue a mesma 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;

Onde vive a posição de scroll de cada pane?

A posição de scroll de cada pane fica dividida entre dois registros, porque quatro panes compartilham só duas posições de linha e duas de coluna. Numa pasta de trabalho clássica, a primeira linha visível dos panes de cima e a primeira coluna visível dos panes da esquerda são Window2.rwTop e Window2.colLeft. A linha dos panes de baixo e a coluna dos panes da direita são Pane.rwTop e Pane.colLeft. O top-right portanto toma a linha do Window2 e a coluna do Pane, e o bottom-left faz o inverso

O ScrollWindow e o TryGetWindowScroll seguem esse layout exatamente e usam números de linha e coluna 1-based. O ScrollWindow(xlspTopRight, R, C) grava Window2.rwTop e Pane.colLeft, o que significa que definir a coluna do top-right também move o pane bottom-right. Isso bate com o Excel, em que os dois panes da direita dividem uma barra de rolagem horizontal. Um pane inexistente faz o ScrollWindow retornar False e faz o TryGetWindowScroll zerar as duas saídas. Uma linha ou coluna fora do intervalo é rejeitada antes de qualquer eixo mudar, então uma coluna ruim não deixa uma linha pela metade para trás

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

  // Bottom-right: eixo de linha inferior (Pane.rwTop) e eixo de coluna direito (Pane.colLeft)
  Sheet.ScrollWindow(xlspBottomRight, 500, 3);

  // Top-right compartilha o eixo de coluna direito, então isso também move o bottom-right para a coluna 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 começa na linha 500, coluna 6
end;
Onde vive cada eixo de scroll de pane no HotXLS: quatro panes compartilham duas posições de linha e duas de coluna, então a linha superior e a coluna da esquerda são Window2.rwTop e Window2.colLeft enquanto a linha inferior e a coluna da direita são Pane.rwTop e Pane.colLeft, e ScrollWindow(xlspTopRight, 1, 6) grava um campo Window2 mais um campo Pane para o bottom-right seguir
O XLSX espalha os mesmos dados pelos atributos topLeftCell do sheetView e do pane, e colapsar as duas camadas numa só é exatamente como uma posição de scroll superior ou esquerda desaparece em silêncio na carga

O XLSX guarda as mesmas duas camadas em dois elementos: sheetView/@topLeftCell (ECMA-376 Part 1, §18.3.1.87) para a janela como um todo e o filho pane/@topLeftCell (§18.3.1.66) para o lado inferior-direito de um split. Os dois atributos podem estar presentes ao mesmo tempo. Um loader que fica com só um deles perde ou as linhas de cima ou as colunas da esquerda de toda planilha congelada que tocar. O TXLSXWorksheet lê o atributo externo nos campos de nível de janela primeiro, deixa o filho pane sobrepor só os campos de nível de pane, e grava os dois de volta separadamente. O ScrollWindow e o TryGetWindowScroll têm as mesmas assinaturas e o mesmo significado no TXLSXWorksheet, e copiar uma planilha carrega as duas camadas em qualquer formato

As propriedades mais estreitas continuam lá para código que só precisa de um par de eixos: ScrollRow e ScrollColumn na planilha clássica, e o SetPaneScroll e o GetPaneScroll zero-based para o pane inferior-direito no XLSX. Um controle de renderização mantém o TopRow e o LeftCol próprios, como descreve o artigo sobre renderizar pastas de trabalho numa grade VCL customizada, e esse estado de runtime é separado do que é gravado. A geometria de freeze e split em si é definida com as configurações de nível de planilha cobertas em proteção de planilha, configuração de página e impressão

Registros Selection corrompidos são mantidos byte a byte

Quando um registro Selection está malformado, o HotXLS mantém o corpo dele como bytes opacos, reporta o código de diagnóstico 1304 (xlsDiagnosticSelectionRecordInvalid) como aviso, e regrava o corpo original sem mudanças no save. Arquivos de exportadores desconhecidos ou escritores BIFF feitos à mão podem conter registros assim. Antes de um registro entrar no grupo do pane dele, o leitor checa um byte de pane de 3 ou menos, registros contíguos por pane, cref entre 1 e 1.369 com corpo de exatamente 9 + cref × 6 bytes, blocos que concordam na célula ativa e um irefAct sem o bit de sinal, uma coluna ativa na grade, e nenhuma área invertida. Problemas dentro de um único registro físico são reportados uma vez por registro; contradições que só aparecem depois da agregação, como um irefAct apontando além da contagem total de áreas ou uma célula ativa fora da área indexada, são reportadas uma vez por grupo no EOF

Um grupo inválido fica fora da API tipada: o GetSelectedAreas retorna um array vazio com índice -1 para aquele pane, e os outros panes seguem funcionando 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 é um TStrings seu; o HotXLS só preenche Diagnostics
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

Consertar o registro, limitando o irefAct ou descartando a área invertida, era a outra opção. Preservá-lo é o padrão mais seguro: uma seleção reparada é uma que ninguém escolheu, e os bytes originais teriam ido embora. A mensagem de diagnóstico nomeia a checagem que falhou, como "the area count does not match the record length" ou "records for the same pane are not contiguous", e o RecordId é sempre $001D para esse código

Inserções de linha e coluna remapeiam a seleção de todo pane

Inserir ou apagar linhas ou colunas inteiras remapeia a seleção de todo pane por uma rotina compartilhada, no motor clássico e no XLSX. As áreas que sobrevivem mantêm a ordem e se deslocam com as células que cobrem, e a área ativa mantém a identidade. Se a área ativa é apagada, a primeira área sobrevivente depois dela vira ativa; se nada vem depois, a última área sobrevivente antes dela. Se toda área é apagada, o grupo colapsa para uma única célula no limite da exclusão. Uma célula ativa que não cai mais dentro da área escolhida se move para o canto superior esquerdo dessa área, para que índice e coordenada nunca se contradigam no arquivo salvo

Grupos marcados como inválidos na carga são deixados em paz pelo remapper, então os bytes originais deles continuam fazendo round-trip depois da edição. Mudar a seleção num pane reescreve só os registros daquele pane e deixa os registros dos outros panes byte-idênticos. Arquivos ODS não carregam estado de seleção de pane nenhum, porque o OpenDocument não tem estrutura equivalente de view de planilha por pane; seleções e posições de scroll de pane se aplicam a XLS e XLSX

Se a sua aplicação salva pastas de trabalho que as pessoas abrem para revisar células marcadas, retomar de onde pararam ou ler um dashboard congelado, as chamadas de seleção e scroll conscientes de pane vêm com o HotXLS Delphi spreadsheet component e se comportam do mesmo jeito para saída .xls e .xlsx