기술 문서

HotXLS로 만드는 Delphi 커스텀 스프레드시트 그리드

HotXLS는 TXLSWorkbookViewer를 제공한다. 이는 Excel을 설치하거나 OLE 자동화로 구동하지 않고도 Delphi나 C++Builder 폼 안에서 XLS, XLSX, XLSM, ODS 워크북을 인터랙티브 스프레드시트 그리드로 렌더링하는 네이티브 VCL 컨트롤이다. 이런 종류의 컨트롤을 잘 만든다는 것은 세 가지 구체적인 문제를 해결한다는 뜻이다: 병합된 셀 안에 떨어진 마우스 클릭을 올바른 논리적 셀에 매핑하는 것, 사용자가 화면에 보이는 창보다 훨씬 큰 시트를 이리저리 움직일 때 스크롤 위치·헤더 밴드·셀 선택을 일관되게 유지하는 것, 그리고 코멘트 마커나 하이퍼링크 셀에 대한 클릭이 실제로 무엇을 해야 하는지 결정하는 것이다

대부분의 Delphi 업체가 스프레드시트 뷰어를 찾는 이유는 편집과는 전혀 무관하다: 업로드된 워크북이 파이프라인에 들어가기 전에 미리 보는 감사 스테이션, Microsoft Office가 배포 이미지에 포함되지 않은 키오스크나 보고서 뷰어, 또는 COM을 통해 실제 Excel 프로세스를 자동화하는 예측 불가능성 없이 워크북 내용을 보여줘야 하는 QA 도구 같은 것이다. 평범한 문자열 그리드는 셀 안에 텍스트를 빠르게 넣어주지만, 스프레드시트 파일은 평범한 그리드가 아니다: 셀은 밑에 있는 모델 안에서 딱 한 번만 존재하는 블록으로 병합되고, 시트는 고정된 헤더 밴드와 독립적인 수평·수직 스크롤 위치를 가지며, 개별 셀은 자신만의 상호작용 모델이 필요한 코멘트와 하이퍼링크를 담는다. TXLSWorkbookViewer는 그 공백에 대한 HotXLS의 답이며, 그 내부 설계는 비슷한 컨트롤을 처음부터 만들려는 누구에게든 타당한 청사진이다

워크북 뷰어는 어떻게 Excel 의존성을 피하는가?

TXLSWorkbookViewer는 Excel을 통해 문서를 열고 그것을 조종하는 대신 HotXLS 자체의 파싱된 객체 모델을 통해 읽음으로써 Excel 의존성을 완전히 피한다. Workbook 속성은 고전 XLS 파일용으로 기존 TXLSWorkbook을 바인딩하고, XlsxWorkbook은 XLSX, XLSM, 템플릿 변형용으로 TXLSXWorkbook을 바인딩한다. 둘 중 어느 것이든 애플리케이션의 다른 곳에서 이미 열려 있을 수 있으며, 뷰어는 그것을 읽기만 한다. 컨트롤이 파일 자체를 소유해야 할 때는 LoadFromFile이 확장자를 검사해 XLSX, XLSM, XLTX, XLTM, ODS는 최신 엔진으로, 나머지는 모두 고전 엔진으로 라우팅하고, 컨트롤이 지워지거나 파괴되면 자신이 만든 워크북을 해제한다

var
  Viewer: TXLSWorkbookViewer;
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  if Book.Open('quarterly-report.xlsx') <> 1 then
    raise Exception.Create('Could not open workbook');

  Viewer := TXLSWorkbookViewer.Create(Self);
  Viewer.Parent := Self;
  Viewer.Align := alClient;
  Viewer.XlsxWorkbook := Book;        // the viewer does not take ownership
  Viewer.GoToCell(1, 1);

  Caption := Viewer.WorksheetName + ': ' + Viewer.SelectedCellText;
end;

병합된 범위 안에서 올바른 셀 찾아내기

TXLSWorkbookViewer가 클릭을 올바른 셀로 해석하는 것은 2단계 조회이며, 이 분리가 중요한 이유는 픽셀 지오메트리와 스프레드시트 의미론이 진짜로 서로 다른 문제이기 때문이다. 첫 단계는 순수한 지오메트리다: 비공개 CellAtPoint 메서드는 현재 스크롤 위치에서부터 열 너비와 행 높이를 순회하며 클릭된 X, Y 좌표를 담고 있는 밴드를 찾아내는데, 병합된 셀에 대한 인식은 전혀 없다. 두 번째 단계는 의미론적이다: 마우스 클릭, 화살표 키, Tab, 또는 GoToCell에 대한 직접 호출 등 선택을 바꾸는 모든 경로는 하나의 내부 ChangeSelection 루틴으로 흘러 들어가는데, 이 루틴은 실제로 선택이 바뀌기 전에 원시 행과 열을 병합에 대해 정규화하고 병합의 앵커 셀로 스냅한다

앵커는 병합된 범위의 좌상단 셀이며, 밑에 있는 워크북 모델에서 그 블록 안에서 진짜로 값·서식·코멘트·하이퍼링크를 가지는 유일한 셀이다; 병합이 시각적으로 덮는 다른 모든 셀은 데이터 자체에서는 비어 있다. 고전 XLS 워크북의 경우 앵커는 IXLSRangeCell.MergeArea에서 나오며, 그 RowColumn이 소유 셀을 가리킨다; XLSX와 ODS 워크북의 경우 MergedCells.FindAtTXLSXMergedRange를 반환하며 같은 앵커를 Row1Col1로 노출한다. 그리기는 이와 동등한 문제를 독립적으로 해결한다: 병합된 셀의 사각형을 그 전체 행·열 범위로 확장하고 그 범위 안의 셀들을 건너뛰어, 선택 윤곽선이 앵커 모서리뿐 아니라 병합된 블록 전체를 감싸도록 한다. 병합된 레이아웃을 그저 읽어들이는 것을 넘어 실제로 작성하는 것은 관련되어 있지만 별개인 문제이며, 보고서 템플릿을 위한 병합 셀 레이아웃에 관한 자매 글에서 다룬다

var
  Sheet: TXLSXWorksheet;
begin
  Sheet := Book.Sheets.Add('Summary');
  Sheet.MergeCells(2, 2, 3, 4);       // B2:D3
  Sheet.Cells[2, 2].Value := 'Region totals';

  Viewer.XlsxWorkbook := Book;
  Viewer.GoToCell(3, 4);              // targets the bottom-right corner of the merge
  // SelectedRow is now 2 and SelectedCol is now 2: normalized to the anchor cell
end;

스크롤, 헤더, 선택을 동기화 상태로 유지하는 것은 무엇인가?

TXLSWorkbookViewer는 세 가지 별개의 상태 조각을 일관되게 유지한다: TopRowLeftCol에 담긴 논리적 스크롤 위치, 컨트롤이 CreateParams에서 WS_HSCROLLWS_VSCROLL로 요청하는 네이티브 Windows 스크롤바, 그리고 SelectedRowSelectedCol에 담긴 현재 선택이다. 스크롤바를 드래그하거나 마우스 휠을 돌리면 WM_HSCROLL, WM_VSCROLL, WM_MOUSEWHEEL이 발생하는데, 이는 TopRowLeftCol을 갱신하고 다시 그린다; 선택은 움직이지 않으며, 이는 Excel 자체가 이동과 선택을 분리하는 방식과 일치한다. 이런 갱신 이후 UpdateScrollBars는 새 위치를 SetScrollInfo를 통해 네이티브 스크롤바에 다시 밀어 넣어, 스크롤바 손잡이가 그리드가 실제로 보여주는 것과 절대 어긋나지 않게 한다

키보드 내비게이션은 같은 동기화를 반대 방향으로 실행한다: 보이는 그리드의 가장자리를 넘어 선택을 옮기면 EnsureSelectionVisible이 호출되는데, 이는 행과 열이 커스텀 크기를 가질 수 있으므로 단순히 1씩 증가시키는 대신 실제 열 너비와 행 높이를 누적해 TopRowLeftCol을 밀어내고, 그다음 UpdateScrollBars를 호출해 스크롤바 손잡이가 키보드가 방금 뷰를 어디로 가져갔는지 반영하도록 한다. RowHeaderWidthColumnHeaderHeight로 크기가 정해지는 행 번호·열 문자 헤더 밴드는, TopRowLeftCol이 밑에 있는 데이터를 스크롤하는 동안 화면에 고정된 채로 남는 이 컨트롤의 부분이며, 이 컨트롤이 스스로 하는 "고정"의 전부다: 이는 Excel의 틀 고정(Freeze Panes) 기능이 아니며, 시트의 나머지가 그 옆을 스크롤해 지나가는 동안 임의의 데이터 행이나 열을 고정할 내장 방법은 없다. 완전히 통제할 수 없는 파일에 대해 뷰어를 출시하기 전에 테스트해 볼 가치가 있는 경계 하나: TopRowLeftCol은 워크시트의 실제 사용된 범위에 대해 제한되지 않으므로, 구조적 한계까지 끌어당겨진 스크롤바 손잡이는 실제로 데이터를 가진 마지막 행이나 열 대신 1,048,576행이나 16,384열에 착지해 빈 그리드를 보여줄 수 있다; 이것이 눈에 띌 만큼 큰 워크북은 보통 대용량 워크북 성능 글에서 다루는 로딩 측의 주의도 필요할 만큼 크다

코멘트와 하이퍼링크를 마우스 및 선택 이벤트에 연결하기

TXLSWorkbookViewer는 코멘트와 하이퍼링크를 호버 대상이 아니라 현재 선택된 셀의 속성으로 취급하므로, SelectedCellCommentText, SelectedCellCommentAuthor, SelectedCellHyperlink는 선택이 마우스 클릭으로 옮겨졌든, 화살표 키로 옮겨졌든, GoToCell 호출로 옮겨졌든 OnSelectionChange가 발생할 때마다 갱신된다. 코멘트가 달린 셀은 Excel 자체의 코멘트 표시와 비슷하게 우상단 모서리에 작은 빨간 삼각형이 시각적 신호로 그려지지만, 그 마커는 순전히 시각적일 뿐이다; 컨트롤에는 호버로 촉발되는 툴팁이 내장되어 있지 않으므로, 선택이 아니라 마우스 오버 시 팝업을 원하는 애플리케이션은 그 계층을 스스로 만들어야 한다. 하이퍼링크 활성화도 같은 선택-우선 방식으로 동작한다: 셀을 더블클릭하면 ActivateSelectedCell이 호출되어 SelectedCellHyperlink를 읽고, 비어 있지 않으면 대상 주소와 핸들러가 설정할 var Handled: Boolean 매개변수와 함께 OnHyperlinkClick을 발생시킨다

OnHyperlinkClick이 하지 않는 일도 그만큼 중요하다: TXLSWorkbookViewer는 핸들러가 Handled를 true로 설정하든 false로 남겨두든 상관없이 ShellExecute를 호출하거나 브라우저를 여는 일을 스스로 절대 하지 않는다. 내비게이션, 그리고 무엇이 안전한 대상인지에 대한 모든 판단은 전적으로 호스트 애플리케이션의 책임이며, 이는 자신이 신뢰할 수 있는 내부 도구에 임베드되어 있는지 아니면 고객이 방금 업로드한 파일을 위한 뷰어에 임베드되어 있는지 전혀 알 수 없는 컴포넌트에게 올바른 기본값이다

procedure TMainForm.ViewerSelectionChange(Sender: TObject; Row, Col: Integer);
begin
  if Viewer.SelectedCellCommentText <> '' then
    StatusBar.SimpleText := Viewer.SelectedCellCommentAuthor + ': ' +
      Viewer.SelectedCellCommentText
  else
    StatusBar.SimpleText := Viewer.SelectedCellHyperlink;
end;

procedure TMainForm.ViewerHyperlinkClick(Sender: TObject;
  const Target: WideString; var Handled: Boolean);
begin
  ShellExecute(0, 'open', PWideChar(Target), nil, nil, SW_SHOWNORMAL);
  Handled := True;
end;

선택 범위와 키보드 내비게이션의 한계

TXLSWorkbookViewer의 선택은 항상 단일 논리 셀이며 SelectedRowSelectedCol로 추적된다; 기본 컨트롤에는 사각형 다중 셀 범위 선택이 없으므로, 셀 블록에 대해 작업해야 하는 어떤 기능이든 선택 객체에서 읽어내는 대신 그 위에 직접 만들어야 한다. 키보드 지원은 의도적으로 기본적이다: 화살표 키는 한 번에 한 셀씩 움직이고, Home은 행의 시작으로(Ctrl과 함께면 셀 A1로) 돌아가며, Page Up과 Page Down은 열 행씩 건너뛰고, Tab과 Shift+Tab은 열을 가로질러 이동한다; 데이터 영역의 가장자리로 점프하는 Ctrl+화살표도, Shift로 확장하는 범위 선택도 없으므로, Excel에서 곧바로 넘어온 사용자는 조밀한 시트에서 이 공백을 알아차릴 것이다

열 제한은 병합 정규화를 처리하는 것과 같은 ChangeSelection 병목 지점에서 강제되며, 의도적으로 엔진마다 다르다: 고전 TXLSWorkbook에 바인딩된 뷰어는 BIFF8 형식의 구조적 상한인 256열에서 제한되는 반면, TXLSXWorkbook에 바인딩된 뷰어는 Excel 2007부터 XLSX가 물려받은 최신 16,384열 제한을 존중한다. 행은 어느 쪽이든 1,048,576으로 제한되므로, 같은 뷰어에서 레거시 XLS 파일을 여는 것과 XLSX 파일을 여는 것 사이의 실질적인 차이는 전적으로 그리드가 오른쪽으로 얼마나 멀리까지 허용하느냐에 있다

픽셀 조회, 앵커 정규화, 그리고 몇 개의 메시지 핸들러로 나누어 보면 이 중 어느 것도 특별할 것이 없지만, 실제 파일에서 진짜 병합, 코멘트, 하이퍼링크와 함께 이 셋을 일치시키는 것이 이런 컴포넌트를 만드는 작업의 대부분이다. TXLSWorkbookViewer는 그것이 렌더링해 오는 고전 및 XLSX 객체 모델과 함께 Delphi와 C++Builder용 HotXLS Excel 컴포넌트 표준판에 포함되어 있다