기술 문서

HotXLS의 BIFF8 Selection 레코드와 Pane 스크롤

장부와 맞지 않는 셀 3,000개를 찾아낸 대사 도구는 저장된 .xls가 고정된 헤더 행 아래에 그 3,000개 전부가 선택된 채 열리기를 바랍니다. 종이 위에서는 Selection 레코드 하나입니다. 바이트로는 9 + 3,000 × 6 = 18,009이고 BIFF8 레코드 본문은 8,224에서 끝납니다. 같은 통합 문서는 사소하게 들리지만 그렇지 않은 두 번째 질문도 던집니다. B2에서 고정된 시트에서 "스크롤 위치"는 두 개의 행 오프셋과 두 개의 열 오프셋을 공유하는 네 개의 창이고, 그 네 숫자는 서로 다른 두 레코드에 삽니다

둘 다 포맷 산수입니다. 선택은 리더가 다시 꿰매야 하는 레코드로 잘리고, 창은 읽기 순서를 따르지 않는 코드로 이름 지어지며, 각 스크롤 축은 Excel이 읽는 레코드에 착지합니다

3,000개 선택 영역에 Selection 레코드 셋이 필요한 이유

[MS-XLS] §2.4.248은 Selection 레코드(0x001D)를 영역들이 뒤따르는 9바이트 고정부로 배치합니다. 고정부는 창 바이트와 활성 셀의 rwAct, colAct, 활성 영역 인덱스의 irefAct, 영역 개수의 cref를 담습니다. 각 영역은 16비트 행 둘과 8비트 열 둘의 RefU 구조입니다. 들어가는 최대 개수는 (8,224 − 9) / 6을 내림한 1,369개 영역과 8,223바이트 본문, 한도 아래 1바이트입니다

HotXLS는 레코드당 천장으로 1,369를 씁니다. TXLSWorksheet가 그보다 많은 영역의 선택 그룹을 저장할 때는 같은 창에 대해 연속된 Selection 레코드를 한 번에 1,369개씩 쓰고, 각 레코드는 같은 창 바이트와 활성 셀, 활성 인덱스를 반복합니다. 3,000셀 대사 선택은 1,369, 1,369, 262개 영역을 담은 세 레코드로 나옵니다. 호출자는 그 분할을 결코 보지 못하고 A1 참조 배열만 넘깁니다

var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Diffs: TXLSSelectedAreas;
  I: Integer;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add;
    Sheet.FreezePanes(1, 1);           // 헤더 행과 A열은 제자리

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

    // FreezePanes는 저장된 선택을 지우므로 고정한 뒤 선택하세요.
    // 3000개 영역은 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;

호출 순서가 여기서 중요합니다. FreezePanes는 창 배치를 다시 세우고 시트가 들고 있던 창별 선택을 전부 지우므로, 먼저 한 선택은 저장 시점엔 사라져 있습니다. 고정되지 않은 시트에는 오른쪽 아래 창이 아직 없고 같은 SelectAreas 호출은 그냥 False를 반환합니다. 고정한 다음 선택하세요

선택이 여러 레코드에 걸치면 irefAct는 무엇을 가리킬까?

분할된 선택에서 irefAct는 모든 청크에 걸친 영역의 전체 집계 시퀀스를 인덱싱하고 모든 청크가 같은 값을 실어 다닙니다. 한도보다 한 영역 많은 선택이 이를 구체화합니다. 마지막 영역이 활성인 1,370개 영역은 두 레코드가 되는데, 첫째는 cref 1,369, 둘째는 cref 1이고 둘 다 irefAct 1,369를 실어 다닙니다. 두 번째 레코드에서 그 인덱스는 레코드 자신의 영역 개수보다 큽니다. '한 레코드씩 읽으면' 망가진 것처럼 보이고 두 종류의 리더가 여기서 넘어집니다

  • 각 레코드 안에서 irefAct를 cref와 대조하는 리더는 완전히 유효한 파일을 둘째 청크에서 거부합니다
  • Selection 레코드마다 선택 상태를 갈아끼우는 리더는 마지막 청크만 유지하고 첫 1,369개 영역을 떨어뜨립니다

HotXLS 리더는 같은 판의 연속 레코드를 '한 그룹으로' 이어 붙이고 모든 청크가 활성 행과 활성 열, irefAct에 대해 일치할 것을 요구합니다. irefAct의 범위 검사와 활성 셀이 가리키는 영역 안에 정말 앉아 있는지의 검사는 워크시트의 EOF 레코드가 도착해 전체 시퀀스가 알려졌을 때만 돕니다. 더 일찍 검사하는 편이 코드는 단순하지만 1,369개가 넘는 모든 선택에 대해 엉뚱한 답입니다

HotXLS가 큰 워크시트 선택을 여러 BIFF8 Selection 레코드로 쓰는 이유: 8,224바이트 본문 한도는 9개 고정 바이트와 1369개의 6바이트 RefU 영역을 담으므로 3,000개 영역은 같은 판의 세 레코드, 즉 1369, 1369, 262로 나뉘고, irefAct는 집계 시퀀스를 인덱싱해 마지막이 활성인 1370개 영역은 두 레코드 모두 irefAct 1369를 갖습니다
각 청크는 같은 활성 셀과 인덱스를 반복하고, HotXLS 리더는 같은 판의 연속 레코드를 한 그룹으로 이어 붙이며, 범위 검사는 전체 시퀀스가 알려지는 EOF 레코드에서만 돕니다

쓰는 쪽에서 창 우선 SelectAreas 오버로드에는 자체 1,369 영역 천장이 없습니다. 시트를 만지기 전에 모든 참조를 파싱합니다. $ 표식은 받아들여 떼어 내고, C9:A2 같은 뒤집힌 범위는 A2:C9로 정규화되며, 비었거나 파싱 불가인 항목은 호출 전체를 실패시킵니다. 활성 인덱스는 배열 안에 있으면서 32,767 이하여야 하는데 irefAct는 부호 있는 16비트 필드이기 때문입니다. 실패는 False를 반환하고 이전 선택을 그대로 남깁니다

판 코드는 읽기 순서를 따르지 않는다

Selection 레코드는 자기 창을 포맷이 정의한 숫자 코드로 이름 댑니다. 오른쪽 아래 0, 오른쪽 위 1, 왼쪽 아래 2, 왼쪽 위 3입니다. 공개 열거형 TXLSPanePosition은 읽기 순서, 즉 xlspTopLeft, xlspTopRight, xlspBottomLeft, xlspBottomRight로 선언됩니다. 그래서 Ord(xlspTopLeft)는 0이고 파일에서 0은 오른쪽 아래입니다. 열거형을 창 바이트로 곧장 캐스트하는 라이터는 모든 왼쪽 위 선택을 오른쪽 아래 창에 쓰는데, 둘 다 합법적인 창 코드라 파일은 구조적으로 유효한 채로 남습니다

HotXLS의 창 인식 진입점은 모두 열거형을 명시적 case 문으로 변환하므로 애플리케이션 코드는 숫자 코드를 결코 다루지 않습니다. 창 존재도 현재 고정이나 분할 지오메트리에 대해 검사합니다. 왼쪽 위 창은 언제나 존재하고, 오른쪽 위는 세로 분할을, 왼쪽 아래는 가로 분할을, 오른쪽 아래는 둘 다를 필요로 합니다. 시트가 갖지 않은 창에는 SelectAreas가 False를 반환하고 GetSelectedAreas는 ActiveAreaIndex를 -1로 둔 빈 배열을 반환하며, 어느 호출도 도중에 창이나 선택, 셀을 만들지 않습니다

HotXLS가 TXLSPanePosition을 BIFF8 Selection 창 바이트로 매핑하는 방식: 열거형은 읽기 순서로 선언돼 Ord(xlspTopLeft)가 0이지만 파일은 오른쪽 아래 0, 오른쪽 위 1, 왼쪽 아래 2, 왼쪽 위 3을 정의하므로 모든 창 인식 진입점이 명시적 case 문으로 변환합니다
열거형을 창 바이트로 곧장 캐스트하면 모든 왼쪽 위 선택이 오른쪽 아래 창에 기록되므로, HotXLS는 쓰기 전에 창 존재를 현재 분할이나 고정 지오메트리에 대해 검사합니다

워크시트는 더 오래된 두 인수짜리 SelectAreas(Areas, ActiveAreaIndex)도 유지하는데, 이는 현재 활성인 창에 쓰고 Integer 상태를 반환합니다. 창이 고정된 시트에서는 여러분이 직접 정하지 않은 상태에 대한 추측인 경우가 잦습니다. 고정되거나 분할된 시트에서 도는 무엇이든 먼저 TXLSPanePosition을 받는 오버로드를 쓰세요. 그러면 창이 호출의 일부가 되고 False 결과는 지오메트리와 요청이 어긋났다는 말입니다. 결과를 다시 읽는 건 같은 모양입니다:

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;

각 판의 스크롤 위치는 어디에 사나?

각 창의 스크롤 위치는 두 레코드에 갈라져 있습니다. 네 창이 두 개의 행 위치와 두 개의 열 위치만 공유하기 때문입니다. 클래식 통합 문서에서 '위쪽 판들'의 첫 보이는 행과 '왼쪽 판들'의 첫 보이는 열은 Window2.rwTop과 Window2.colLeft입니다. 아래 판들의 행과 오른쪽 판들의 열은 Pane.rwTop과 Pane.colLeft입니다. 따라서 오른쪽 위는 행을 Window2에서, 열을 Pane에서 가져가고 왼쪽 아래는 반대입니다

ScrollWindow과 TryGetWindowScroll은 그 배치를 정확히 따르고 1 기반 행과 열 번호를 씁니다. ScrollWindow(xlspTopRight, R, C)는 Window2.rwTop과 Pane.colLeft를 쓰는데, 이는 오른쪽 위 열을 정하는 것이 오른쪽 아래 창도 움직인다는 뜻입니다. Excel에서 오른쪽 두 창이 하나의 가로 스크롤바를 공유하는 것과 맞습니다. 없는 창은 ScrollWindow를 False로 만들고 TryGetWindowScroll로 하여금 양쪽 출력을 0으로 두게 합니다. 범위 밖 행이나 열은 어느 축도 바뀌기 전에 거부되므로 나쁜 열이 반쯤 적용된 행을 남길 수 없습니다

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

  // Bottom-right: 아래 행 축(Pane.rwTop)과 오른쪽 열 축(Pane.colLeft)
  Sheet.ScrollWindow(xlspBottomRight, 500, 3);

  // Top-right는 오른쪽 열 축을 공유하므로 이 호출은 bottom-right도 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는 500행, 6열에서 시작
end;
HotXLS 창 스크롤 축이 사는 곳: 네 창이 두 개의 행 위치와 두 개의 열 위치를 공유하므로 위쪽 행과 왼쪽 열은 Window2.rwTop과 Window2.colLeft에, 아래쪽 행과 오른쪽 열은 Pane.rwTop과 Pane.colLeft에 살고, ScrollWindow(xlspTopRight, 1, 6)는 Window2 필드 하나와 Pane 필드 하나를 써 bottom-right가 따라갑니다
XLSX는 같은 데이터를 sheetView와 pane topLeftCell 속성에 흩어 놓으므로, 두 계층을 하나로 뭉개는 것이 적재 때 위쪽이나 왼쪽 스크롤 위치가 조용히 사라지는 정확한 길입니다

XLSX는 같은 두 계층을 두 요소에 저장합니다. 창 전체의 sheetView/@topLeftCell(ECMA-376 Part 1, §18.3.1.87)과 분할의 오른쪽 아래 쪽을 위한 자식 pane/@topLeftCell(§18.3.1.66)입니다. 두 속성은 동시에 존재할 수 있습니다. 그중 하나만 유지하는 로더는 만지는 모든 고정 시트의 위쪽 행들이나 왼쪽 열들을 잃습니다. TXLSXWorksheet는 바깥 속성을 창 수준 필드로 먼저 읽고 pane 자식이 창 수준 필드만 재정의하게 두며, 둘 다 갈라 기록합니다. ScrollWindow와 TryGetWindowScroll은 TXLSXWorksheet에서도 같은 시그니처와 의미를 갖고 워크시트 복사는 어느 포맷이든 두 계층을 실어 나릅니다

한 축 쌍만 필요한 코드를 위한 좁은 프로퍼티도 남아 있습니다. 클래식 워크시트의 ScrollRow와 ScrollColumn, XLSX의 오른쪽 아래 창을 위한 0 기반 SetPaneScroll과 GetPaneScroll입니다. 렌더링 컨트롤은 커스텀 VCL 그리드에서 통합 문서 렌더링하기 글이 서술하듯 자체 TopRow와 LeftCol을 유지하며, 그 런타임 상태는 저장되는 것과 갈라져 있습니다. 고정과 분할 지오메트리 자체는 시트 보호, 페이지 설정, 인쇄에서 다루는 시트 수준 설정으로 겁니다

손상된 Selection 레코드는 바이트 단위로 보존한다

Selection 레코드가 망가졌을 때 HotXLS는 본문을 불투명 바이트로 유지하고, 진단 코드 1304(xlsDiagnosticSelectionRecordInvalid)를 경고로 보고하며, 저장 시 원본 본문을 바꾸지 않고 그대로 기록합니다. 모르는 익스포터나 손으로 만든 BIFF 라이터의 파일은 이런 레코드를 담을 수 있습니다. 레코드가 자기 판의 '그룹'에 합류하기 전에 리더는 3 이하의 창 바이트, 판당 연속 레코드, 정확히 9 + cref × 6바이트 본문을 가진 1에서 1,369 사이의 cref, 활성 셀에 일치하는 청크, 부호 비트 없는 irefAct, 그리드 위의 활성 열, 뒤집힌 영역 없음을 검사합니다. 하나의 물리 레코드 안의 문제는 레코드당 한 번 보고되고, irefAct가 총 영역 수를 넘거나 활성 셀이 인덱스된 영역 밖에 있는 것처럼 집계 뒤에만 드러나는 모순은 EOF에서 그룹당 한 번 보고됩니다

무효한 그룹은 타입화된 API 밖에 머뭅니다. GetSelectedAreas는 그 창에 대해 인덱스 -1의 빈 배열을 반환하고 다른 창들은 정상 동작을 계속합니다

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는 자체 TStrings입니다. HotXLS는 Diagnostics만 채웁니다
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

레코드 수리, 이를 테면 irefAct를 묶거나 뒤집힌 영역을 떨어뜨리는 것은 다른 선택지였습니다. 보존이 더 안전한 기본값입니다. 수리된 선택은 아무도 고르지 않은 것이고 원본 바이트는 사라졌을 테니까요. 진단 메시지는 실패한 검사를 이름 댑니다. "영역 개수가 레코드 길이와 맞지 않습니다"나 "같은 판의 레코드가 연속적이지 않습니다" 같은 식이고, RecordId는 이 코드에서 언제나 $001D입니다

행과 열 삽입은 모든 판의 선택을 재매핑한다

행이나 열 전체를 삽입하거나 삭제하면 클래식과 XLSX 엔진 모두에서 하나의 공유 루틴을 통해 모든 창의 선택이 재매핑됩니다. 살아남은 영역은 순서를 지키며 자기가 덮는 셀과 함께 이동하고 활성 영역은 정체를 유지합니다. 활성 영역이 삭제되면 그 뒤의 첫 살아남은 영역이 활성이 되고, 뒤가 없으면 앞의 마지막 살아남은 영역이 활성이 됩니다. 모든 영역이 삭제되면 그룹은 삭제 경계의 단일 셀로 무너집니다. 더는 고른 영역 안에 놓이지 않는 활성 셀은 그 영역의 왼쪽 위 구석으로 옮겨져, 인덱스와 좌표가 저장 파일에서 서로 모순되는 일이 결코 없습니다

적재 시 '무효'로 표시된 그룹은 재매퍼가 손대지 않으므로 원본 바이트는 편집 뒤에도 왕복합니다. 한 창에서 선택을 바꾸면 그 창의 레코드만 재작성되고 다른 창들의 레코드는 바이트 단위로 동일하게 남습니다. ODS 파일은 창 선택 상태를 전혀 실지 않습니다. OpenDocument에는 창별 워크시트 뷰 구조에 해당하는 것이 없어서, 선택과 창 스크롤 위치는 XLS와 XLSX에 적용됩니다

플래그된 셀을 검토하러 열거나 마지막 자리에서 이어 하거나 고정된 대시보드를 읽는 통합 문서를 저장하는 애플리케이션이라면, 창 인식 선택과 스크롤 호출은 HotXLS Delphi 스프레드시트 컴포넌트에 실려 나가며 .xls와 .xlsx 출력에 똑같이 동작합니다