Artigo Técnico

Registos Selection BIFF8 e scroll de painéis no HotXLS

O HotXLS guarda seleções de folha e posições de scroll por painel através de uma única API consciente de painéis tanto no TXLSWorksheet como no TXLSXWorksheet: SelectAreas, GetSelectedAreas, ScrollWindow e TryGetWindowScroll. Para ficheiros .xls clássicos, o HotXLS escreve registos Selection do BIFF8 (0x001D) com um máximo de 1369 áreas cada, converte nomes lógicos de painéis nos bytes de painel que o formato define, e mantém cada eixo de scroll no registo Window2 ou Pane onde o Excel o espera

O problema costuma aparecer numa ferramenta de reconciliação ou de auditoria. A ferramenta abre uma exportação do livro razão, encontra todas as células que discordam do sistema de origem, e grava o livro com essas células já selecionadas por baixo de uma linha de cabeçalho congelada, para o revisor cair nas diferenças em vez de as andar a procurar com scroll. Com quarenta diferenças funciona bem. O ficheiro de fim de mês tem 3000, e um único registo Selection com 3000 áreas não pode existir: o corpo precisaria de 18 009 bytes, mais do dobro do que um registo BIFF8 consegue transportar. A posição de scroll tem uma armadilha parecida. Numa folha com painéis congelados, «onde o utilizador estava a olhar» são quatro painéis a partilhar duas posições de linha e duas posições de coluna, e não uma coordenada

Porque é que uma seleção grande precisa de mais do que um registo Selection?

Uma seleção grande precisa de vários registos porque o corpo de um registo BIFF8 está limitado a 8224 bytes e cada área selecionada custa seis bytes fixos. O [MS-XLS] §2.4.248 desenha o registo Selection como uma parte fixa de 9 bytes (o byte de painel, rwAct e colAct para a célula ativa, irefAct para a área ativa, e cref para a contagem de áreas) seguida de cref estruturas RefU, cada uma com duas linhas de 16 bits e duas colunas de 8 bits. A maior contagem que cabe é (8224 − 9) / 6 arredondado para baixo, que dá 1369, e produz um corpo de 8223 bytes, um byte abaixo do limite. O TXLSWorksheet.StoreSelectionGroup usa essa constante como MaxAreasPerRecord e escreve um grupo maior como registos Selection consecutivos do mesmo painel, 1369 áreas de cada vez

O detalhe que morde é o irefAct. Cada bloco repete a mesma linha ativa, coluna ativa e índice de área ativa, e o irefAct indexa a sequência agregada de todos os blocos, não as áreas dentro do registo que o transporta. Uma seleção uma área acima do limite torna isto concreto: 1370 áreas com a última ativa tornam-se dois registos, o primeiro com cref 1369 e o segundo com cref 1, e ambos transportam irefAct 1369. Esse valor é maior do que a contagem de áreas do próprio segundo registo. Um leitor que verifique o irefAct contra o cref em cada registo recusa um ficheiro válido, e um leitor que substitua o seu estado em cada registo deita fora as primeiras 1369 áreas. O leitor do HotXLS acrescenta registos consecutivos do mesmo painel a um único grupo, exige que todos os blocos concordem na célula ativa e no índice, e corre a verificação de intervalo só no registo EOF da folha, quando a sequência completa já é conhecida. A sobrecarga do SelectAreas que recebe o painel primeiro não tem portanto teto de 1369 áreas. Valida todas as referências A1 e o índice ativo antes de tomar o lock de escrita da folha, e devolve False com a seleção anterior intacta se algo estiver malformado

Porque é que o HotXLS escreve uma seleção de folha grande como vários registos Selection do BIFF8: o limite de corpo de 8224 bytes cabe em 9 bytes fixos mais 1369 áreas RefU de seis bytes, pelo que 3000 áreas se tornam três registos do mesmo painel de 1369, 1369 e 262, e o irefAct indexa a sequência agregada, pelo que 1370 áreas com a última ativa dão a ambos os registos irefAct 1369
Cada bloco repete a mesma célula ativa e índice, o leitor do HotXLS acrescenta registos consecutivos do mesmo painel a um único grupo, e a verificação de intervalo corre só no registo EOF quando a sequência completa já é conhecida
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 no sítio

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

    // Congelar repõe a seleção guardada, por isso selecione depois de congelar.
    // 3000 áreas são gravadas como três registos 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;

Que byte de painel usa um registo Selection?

Um registo Selection identifica o seu painel pelo código numérico que o formato define: 0 para inferior-direito, 1 para superior-direito, 2 para inferior-esquerdo e 3 para superior-esquerdo. A enumeração pública TXLSPanePosition é declarada por ordem de leitura, xlspTopLeft, xlspTopRight, xlspBottomLeft, xlspBottomRight, pelo que Ord(xlspTopLeft) é 0, que é o painel inferior-direito no ficheiro. Fazer cast da enumeração diretamente para o byte de painel escreveria todas as seleções superior-esquerdo no painel inferior-direito sem qualquer erro. Todos os pontos de entrada do HotXLS conscientes de painéis convertem a enumeração através de uma instrução case explícita, pelo que quem chama nunca lida com os códigos numéricos. A existência do painel também é verificada: o painel superior-direito só existe com uma divisão vertical, o inferior-esquerdo só com uma horizontal, e o inferior-direito só com ambas. Para um painel que a geometria atual de divisão ou congelação não tenha, o SelectAreas devolve False, e o GetSelectedAreas devolve uma matriz vazia com ActiveAreaIndex a -1, sem criar painel, objeto de seleção ou célula no livro

Como o HotXLS mapeia TXLSPanePosition no byte de painel Selection do BIFF8: a enumeração é declarada por ordem de leitura, pelo que Ord(xlspTopLeft) é 0, enquanto o ficheiro define 0 para inferior-direito, 1 para superior-direito, 2 para inferior-esquerdo e 3 para superior-esquerdo, pelo que todos os pontos de entrada conscientes de painéis convertem através de uma instrução case explícita
Fazer cast da enumeração diretamente para o byte de painel escreveria todas as seleções superior-esquerdo no painel inferior-direito, por isso o HotXLS também verifica a existência do painel contra a geometria atual de divisão ou congelação antes de escrever

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

A posição de scroll de cada painel está dividida por dois registos, porque quatro painéis partilham apenas duas posições de linha e duas de coluna. Num livro clássico, a primeira linha visível dos painéis superiores e a primeira coluna visível dos painéis esquerdos são Window2.rwTop e Window2.colLeft, enquanto a linha dos painéis inferiores e a coluna dos painéis direitos são Pane.rwTop e Pane.colLeft. O ScrollWindow(xlspTopRight, R, C) escreve portanto Window2.rwTop e Pane.colLeft, e definir a coluna do painel superior-direito move também o painel inferior-direito, tal como os dois partilham uma única barra de scroll horizontal no Excel. Os métodos públicos usam números de linha e coluna base 1. Um painel inexistente devolve False e põe as duas saídas da consulta a zero, e uma coordenada fora do intervalo é recusada antes de qualquer eixo mudar. Nada disto depende da forma como um visualizador pinta a grelha. Um controlo de renderização mantém o seu próprio TopRow e LeftCol, como descreve o artigo sobre renderizar livros numa grelha VCL personalizada, e esses são estado de execução, não o que é gravado

Onde vive cada eixo de scroll de painel do HotXLS: quatro painéis partilham duas posições de linha e duas de coluna, pelo que a linha superior e a coluna esquerda são Window2.rwTop e Window2.colLeft enquanto a linha inferior e a coluna direita são Pane.rwTop e Pane.colLeft, e o ScrollWindow(xlspTopRight, 1, 6) escreve um campo Window2 mais um campo Pane para o inferior-direito seguir
O XLSX espalha os mesmos dados pelos atributos topLeftCell de sheetView e de pane, e colapsar as duas camadas numa só é precisamente a forma como uma posição de scroll superior ou esquerda desaparece em silêncio ao carregar

O XLSX espalha os mesmos dados por dois elementos: sheetView/@topLeftCell (ECMA-376 Parte 1, §18.3.1.87) para a janela como um todo, e o elemento subordinado pane/@topLeftCell (§18.3.1.66) para o lado inferior-direito de uma divisão. Os dois atributos podem estar presentes ao mesmo tempo. O HotXLS lê primeiro o atributo exterior para os campos ao nível da janela, deixa o elemento pane subordinado sobrepor-se apenas aos campos ao nível do painel, e escreve ambos de volta separadamente. Colapsar as duas camadas numa só é precisamente a forma como uma posição de scroll superior ou esquerda desaparece em silêncio ao carregar. As cópias de folhas transportam ambas as camadas em ambos os motores. Os pontos de entrada mais antigos mantêm o comportamento original: as propriedades clássicas ScrollRow e ScrollColumn, e o SetPaneScroll e GetPaneScroll do XLSX base zero. A geometria de congelação e divisão em si configura-se com as definições ao nível da folha cobertas em proteção de folhas, configuração de página e impressão

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

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

  // Superior-direito partilha o eixo de coluna direita, pelo que isto também move o inferior-direito 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]));
    // Inferior-direito começa na linha 500, coluna 6
end;

O que acontece quando um registo Selection está corrompido?

Quando um registo Selection está corrompido, o HotXLS mantém-no como bytes opacos, reporta o código de diagnóstico 1304 (xlsDiagnosticSelectionRecordInvalid), e escreve o corpo original de volta byte a byte ao gravar. Antes de um registo se juntar ao grupo do seu painel, o leitor verifica-o por ordem. O byte de painel tem de ser 3 ou menos. Os registos de um painel têm de ser contíguos no stream. Os 9 bytes fixos têm de estar presentes. O cref tem de estar entre 1 e 1369, e o corpo tem de ter exatamente 9 + cref × 6 bytes. Todos os blocos de um grupo têm de concordar na célula ativa e no irefAct, o irefAct não pode ter o bit de sinal ativado, a coluna ativa tem de estar na grelha, e nenhuma área pode ter limites invertidos. Os problemas de um único registo físico são reportados uma vez por registo. As contradições que só aparecem depois da agregação, como o irefAct a apontar para 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 permanece invisível para a API tipada: o GetSelectedAreas devolve uma matriz vazia com índice -1 para esse painel, enquanto todos os outros painéis continuam a funcionar

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.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

Como sobrevivem as seleções a inserções de linhas e colunas?

As seleções sobrevivem a edições estruturais porque inserir ou eliminar linhas ou colunas inteiras remapeia todos os grupos de painéis representados, tanto no motor clássico como no XLSX, através de um único remapeador partilhado. As áreas que sobrevivem conservam a sua ordem e a área ativa conserva a sua identidade. Se a área ativa for eliminada, a primeira área sucessora sobrevivente torna-se ativa, e se nada a seguir, a última área predecessora sobrevivente. Se todas as áreas forem eliminadas, o grupo colapsa para uma célula no limite de eliminação, e uma célula ativa que deixe de cair dentro da área escolhida move-se para o canto superior esquerdo dessa área, para que o índice e a coordenada nunca se contradigam. Os limites são deliberados. Os grupos clássicos inválidos são ignorados pelo remapeador em vez de serem reescritos numa seleção inventada, pelo que os seus bytes originais continuam a fazer round-trip. Editar um painel substitui apenas os registos desse painel e deixa os outros byte a byte idênticos. O ODS não recebe qualquer estado de seleção de painéis, porque o ODF não tem estrutura equivalente de vista de folha para o transportar

Se a sua aplicação escreve ficheiros .xls que os utilizadores abrem e precisam de navegar, seja para rever células assinaladas, retomar onde pararam ou partilhar um dashboard congelado, a API de seleção e scroll consciente de painéis faz parte do componente de folhas de cálculo HotXLS para Delphi, e funciona da mesma forma para XLS e XLSX