기술 문서

Delphi HotXLS에서 ODS 반복 행을 행 높이 런으로 처리

HotXLS Delphi Component은 table:number-rows-repeated와 행 높이를 지닌 ODS 행을 반복 행마다 높이 항목을 하나씩 두는 대신 첫 행, 끝 행, 높이 하나로 이루어진 단일 TXLSXRowHeightRun 레코드로 저장하고, 그 행들이 물려받는 빈 셀 스타일을 하나의 구간 스타일 오버레이로 접습니다. 꼬리에서 빈 행 1,048,530개가 반복되는 스프레드시트를 HotXLS 2.382.1이 타임아웃시키던 자리에서 2.382.2가 0.02초에 여는 이유가 전부 여기 있고, 같은 파일을 ODS로 저장할 때 백만 개의 실제 행이 아니라 반복 수를 그대로 유지해 되쓰는 이유도 여기 있습니다

문제의 파일은 평범합니다. LibreOffice Calc는 데이터 45행이 있는 14열 시트를 쓴 다음 그 아래 전부를 요소 하나로 설명합니다. <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>입니다. 스타일 ro1은 style:row-height="0.452cm"를 설정하고, 각 <table:table-column>은 그 런의 모든 빈 셀이 물려받는 table:default-cell-style-name을 지닙니다. content.xml 전체가 103 KB입니다. 파일 어디에도 비싸다고 말하는 곳은 없습니다. 비용은 전적으로 우리 쪽이었습니다

HotXLS가 ODS 반복 행 하나를 압축된 상태로 바꾸는 방식: table:number-rows-repeated 1048530과 스타일 ro1을 지닌 content.xml 요소가 46행부터 1048575행까지를 12.81pt로 덮는 단일 TXLSXRowHeightRun 레코드와 열마다 하나의 StyleOverlays 항목으로 매핑되는 반면, 2.382.1은 같은 요소를 백만 개의 SetRowHeight 항목과 셀 객체로 확장했습니다
반복 수와 ro1 행 높이, 열 기본 스타일이 45행 아래의 모든 빈 행을 설명하므로, 임포터는 백만 개의 좌표를 건드리지 않고 런 레코드 하나와 열별 오버레이를 만들 수 있습니다

반복 행 하나가 ODS 임포트를 타임아웃시키는 이유

예전 임포터가 그것을 확장했기 때문입니다. 2.382.1에서 행 마무리 루프는 반복 행마다 SetRowHeight(RowIndex + i, RowHeight)를 한 번씩 돌며 각 높이를 행 번호로 키를 잡은 Name=Value 문자열 목록에 썼습니다. 그 목록에 삽입할 때마다 이미 들어 있는 전부를 대상으로 IndexOfName 조회가 실행되어, 백만 개 높이에 백만 번의 선형 탐색이 들었습니다. HXLS-005가 제기된 그 이차 시간 목록 검색입니다. 동시에 OdsCommitRow는 스타일을 물려받은 모든 열에 대해, 그것도 반복 행마다 셀 객체를 실체화했습니다. 스타일이 있는 빈 셀도 여전히 셀로 세어졌기 때문입니다

저장 쪽에도 같은 문제의 다른 판본이 있었습니다. LibreOffice 파일은 큰 반복 뒤에 ro1 행이 하나 더 있으므로 스타일이 있는 가장 높은 행이 시트 맨 아래에 있었고, OdsBuildTableXml은 그 행까지 모든 행을 걸어가며 <table:table-row> 요소를 하나씩 내보냈습니다. 임포트를 싸게 마친 통합 문서라도 저장은 비싸게 했을 것입니다. 임포트만 고치고 익스포트를 두면 타임아웃이 사라지는 것이 아니라 자리만 옮겼을 것입니다

HotXLS에서 행 높이 런이란

런은 46행부터 1,048,575행까지가 모두 12.81포인트 높이라는 사실을 1,048,530번 말하지 않고 표현할 수 있는 가장 작은 단위입니다. TXLSXRowHeightRun은 FirstRow, LastRow, Height로 이루어진 레코드이고, TXLSXRowHeightRuns는 그것들의 동적 배열이며, 각 TXLSXWorksheet가 기존 행별 높이 목록 옆에 FRowHeightRuns로 하나를 유지합니다. ODS 임포트에서 행 마무리 루프는 이제 반복 수로 분기합니다. 수가 1이면 여전히 SetRowHeight를 호출하고, 그보다 크면 XlsxAssignRowHeightRun을 구간 전체에 대해 한 번 호출합니다. 구간은 1,048,576인 XlsxMaxRow로 잘리므로, 시트를 넘어서는 반복 수는 거부되지 않고 잘립니다

XlsxAssignRowHeightRun은 그 배열의 유일한 작성자이며, 구조적으로 런들이 서로 겹치지 않게 유지합니다. 새 구간이 주어지면 완전히 바깥에 있는 기존 런은 전부 복사하고, 겹치는 런은 앞 조각과 뒤 조각으로 나눈 뒤, Present가 참이면 새 구간을 덧붙입니다. Present가 거짓이면 아무것도 덧붙이지 않는데, ClearRowHeight가 한 행짜리 구멍을 뚫는 방식이 그것입니다. 따라오는 결론은 두 가지입니다. 배열에는 겹치는 구간이 절대 없으므로 조회가 첫 적중에서 멈출 수 있습니다. 그리고 배열은 제자리에서 변경되지 않습니다. 호출마다 새 복사본을 만들며, 이 정도 크기에서는 비용이 없고 온갖 종류의 앨리어싱 버그를 통째로 없앱니다

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // 꼬리 행이 하나의 행 스타일로 1,048,530번 반복되는 시트
    Workbook.OpenODS('conditional-formatting.ods');
    Sheet := Workbook.Sheets[1];
    // 두 읽기 모두 같은 런으로 해석되며 확장된 것은 없음
    Writeln(Sheet.RowHeight[46]:0:2, ' pt');
    Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
    // 한 행짜리 오버라이드는 런을 쪼개지 않고 덮음
    Sheet.RowHeight[500000] := 36;
    // 런 안의 한 행을 지우면 런이 두 조각으로 갈라짐
    Sheet.ClearRowHeight(500001);
    Writeln(Sheet.HasRowHeight(500001)); // False
    Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // 여전히 런 높이
  finally
    Workbook.Free;
  end;
end;

기억할 가치가 있는 부분은 조회 순서입니다. TXLSXWorksheet.GetRowHeight는 행별 목록을 먼저 확인하고 그 행에 명시적 항목이 없을 때만 런을 참조하며, HasRowHeight도 같습니다. 그래서 Sheet.RowHeight[500000] := 36은 런을 전혀 건드리지 않고 행별 목록에 항목 하나를 추가하며, 그 항목이 먼저 조회되므로 이깁니다. ClearRowHeight는 반대입니다. 행별 항목을 제거한 뒤 Present = False로 XlsxAssignRowHeightRun을 호출합니다. 지운 행은 런이 덮고 있더라도 높이 없음으로 읽혀야 하기 때문입니다. ClearRowHeights는 두 구조를 한꺼번에 비웁니다

HotXLS에서의 행 높이 런 수술: OpenODS 후 하나의 런이 46행부터 1048575행을 12.81pt로 덮고, 행별 항목이 500000행을 36pt로 설정해 GetRowHeight가 행별 목록을 먼저 확인하므로 그 값이 이기며, 500001행을 ClearRowHeight하면 런이 구멍을 사이에 두고 서로 겹치지 않는 두 조각으로 갈라집니다
XlsxAssignRowHeightRun은 지운 구간 바깥의 조각들을 복사하고 그 구간 자체에는 아무것도 덧붙이지 않으므로, 런은 구조적으로 겹치지 않고 조회는 첫 적중에서 멈출 수 있으며 500000행의 오버라이드는 그대로 남습니다

물려받은 빈 셀 스타일은 어디로 갈까

셀 객체가 아니라 열마다 하나의 구간 스타일 오버레이로 갑니다. OdsCommitRow는 열 값마다 그것이 압축 가능한 빈 셀인지 판단합니다. 행이 두 번 이상 반복되고, 셀에 값도 수식도 서식 있는 텍스트도 없는 경우입니다. 압축 가능한 빈 셀이면 런의 첫 행에서만 실제 셀을 만들고 물려받은 스타일을 적용한 뒤, 같은 여섯 개 스타일 인덱스 — 폰트, 채우기, 테두리, 숫자 형식, 정렬, 보호 — 를 그 열의 두 번째 행부터 런 끝까지를 덮는 StyleOverlays.Add로 등록합니다. 첫 행 뒤의 행들은 실체화 루프에서 완전히 건너뜁니다

회귀 테스트가 그 형태를 구체적으로 보여 줍니다. 두 번째 행이 굵은 열 기본 스타일로 1,048,575번 반복되는 시트를 연 뒤 Sheet.Cells.Count가 10 미만임을 단언하고, Sheet.Cells[700000, 1].FontIndex는 여전히 굵은 폰트로 해석됩니다. 그 좌표를 건드리는 순간 오버레이가 스타일을 공급하기 때문입니다. 서식만 있고 비어 있는 열이 XLSX 쪽에서 백만 개 셀 비용을 내지 않게 하는 것과 같은 메커니즘입니다. 행 블록 셀 저장과 구간 스타일 오버레이 노트에서 오버레이가 어떻게 쌓이고 해석되는지 다룹니다. 여기서 새로운 점은 ODS 임포터가 애플리케이션이 범위를 서식 지정하기를 기다리지 않고 반복 수로부터 스스로 오버레이를 만든다는 것입니다

SaveAsODS는 반복 수를 어떻게 되쓸까

실제로 무언가 바뀌는 지점에서만 시트의 빈 꼬리를 쪼개서 되씁니다. 이제 OdsBuildTableXml은 두 경계를 추적합니다. 값이나 수식, 하이퍼링크, 수동 행 나눔을 지닌 마지막 행인 contentMaxRow와, 여기에 스타일만 있는 빈 셀, 한 행짜리 높이, 모든 런의 LastRow, 모든 오버레이의 아래 모서리까지 더 확장한 maxRow입니다. 스타일만 있는 빈 셀은 더 이상 내용으로 세어지지 않습니다. TXLSXCells.IsStyleOnlyBlank가 그것을 제외하며, 그래서 LibreOffice 파일의 꼬리 스타일 행이 내용 경계를 시트 바닥까지 끌고 가지 않습니다

contentMaxRow 위쪽은 예전과 똑같이 행을 하나씩 씁니다. 그 아래에서 작성기는 nextRow를 다음 런의 FirstRow, 현재 런의 LastRow + 1, 다음 한 행짜리 높이 항목, 다음 오버레이 모서리, 다음 실체화된 셀 중 가장 작은 값으로 계산합니다. 그런 다음 현재 행부터 nextRow - 1까지를 하나의 <table:table-row>로 내보내며 table:number-rows-repeated를 그 차이로 설정하고, 오버레이가 그 열을 덮으면 오버레이가 해석한 스타일 이름을 붙인 <table:table-cell/>를 열마다 하나씩 답니다. 행 스타일 자체는 TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec)에서 오는데, 이제 높이 텍스트 — 예컨대 12.81pt — 를 숨김 및 페이지 나눔 플래그와 함께 중복 제거 키에 접어 넣으므로, 런의 모든 행이 하나의 style:row-height 속성을 지닌 ro<N> 스타일 하나를 공유합니다

런으로 뒷받침되는 시트에 SaveAsODS가 쓰는 내용: contentMaxRow는 값이 끝나는 45행에서 멈추고 maxRow는 높이 런과 그 오버라이드까지 확장되며, 경계 위의 행들은 하나씩 쓰이고, 꼬리는 반복 table-row 요소로 내보내지는데 행 스타일은 RowStyleFor에서 오고 셀 스타일은 오버레이를 통해 해석됩니다
각 반복 요소는 균일한 구간 하나를 덮고 다음 런 모서리, 높이 항목, 오버레이 모서리, 실체화된 셀에서 멈추므로, 검증이 없는 시트는 요소 몇 개로 저장되고 검증이나 XLSX 익스포트는 행마다 비용을 냅니다
var
  Workbook, Reopened: TXLSXWorkbook;
  Saved: TMemoryStream;
begin
  Workbook := TXLSXWorkbook.Create;
  Reopened := TXLSXWorkbook.Create;
  Saved := TMemoryStream.Create;
  try
    Workbook.OpenODS('conditional-formatting.ods');
    Workbook.Sheets[1].RowHeight[500000] := 36;
    Workbook.Sheets[1].ClearRowHeight(500001);
    // 빈 꼬리는 백만 개가 아니라 반복 행 몇 개로 쓰임
    Workbook.SaveAsODS(Saved);
    Writeln('ODS size: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.Open(Saved);
    // 오버라이드, 구멍, 런이 모두 왕복을 견딤
    Writeln(Reopened.Sheets[1].RowHeight[500000]:0:2);   // 36.00
    Writeln(Reopened.Sheets[1].HasRowHeight(500001));    // False
    Writeln(Reopened.Sheets[1].RowHeight[500002]:0:2);   // 런 높이
  finally
    Saved.Free;
    Reopened.Free;
    Workbook.Free;
  end;
end;

이것을 고정하는 테스트는 높이 런이 오버라이드와 중간에 뚫린 구멍과 함께 1,048,575행을 아우르는 시트에 대해 저장된 스트림이 64 KB 미만임을 단언합니다. 그 숫자 옆에 정직한 경계 두 개가 따라붙습니다. 첫째, 데이터 유효성 검사가 있는 워크시트는 contentMaxRow를 maxRow로 설정하므로, 검증이 있으면 그 시트의 꼬리 압축이 꺼지고 다시 행 단위로 기록됩니다. 둘째, XLSX에는 반복 속성이 없습니다. SpreadsheetML의 <row>는 행 하나를 기술하므로, 런으로 뒷받침되는 시트를 .xlsx로 내보내면 런이 덮는 행을 열거하며 각각에 ht 속성을 씁니다. 모델은 메모리에서 압축된 채로 남고, 파일 형식이 파일의 모양을 결정합니다

행 번호를 바꾸는 모든 편집이 이제 런에 대해 무엇을 해야 할까

유지보수입니다. 행 메타데이터의 새 표현은 행 번호를 바꾸는 모든 연산이 옆에 있는 행별 목록과 함께 그것을 옮겨 줄 때만 올바르며, 이번 커밋은 그 연산들 각각을 건드립니다. InsertRows와 DeleteRows는 XlsxShiftRowHeightRuns를 거치는데, 편집 지점 앞에 있는 각 런의 부분을 남기고 삭제 창 안에 들어가는 것은 버리며 나머지는 델타만큼 이동해 다시 추가합니다. 그래서 삽입을 가로지르는 런은 틈이 있는 두 런이 되고, 삭제를 가로지르는 런은 줄어듭니다. TileRangeAxisMetadata는 타일 전체 구간에 걸쳐 런을 비운 뒤 각 소스 런을 복사본마다 그 오프셋에 한 번씩 다시 등록합니다. TXLSXWorksheet.CopyFrom과 TXLSXSheets.AddCopy는 배열을 대입하지 않고 Copy()를 받습니다. 그래서 테스트가 복제본의 모든 높이를 지우고도 원본 시트가 1,048,576행에서 멀쩡한 것을 확인할 수 있습니다

var
  Sheet: TXLSXWorksheet;
begin
  Sheet := Workbook.Sheets[1];
  Sheet.RowHeight[500000] := 36;
  Sheet.ClearRowHeight(500001);
  // 500000에 두 행 삽입: 오버라이드는 500002로, 구멍은 500003으로 이동
  Sheet.InsertRows(500000, 2);
  Writeln(Sheet.RowHeight[500002]:0:2);   // 36.00
  Writeln(Sheet.HasRowHeight(500003));    // False
  // 다시 삭제: 모든 것이 원래대로 이동
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);   // 36.00
  // 2..4행을 시트 아래로 두 번 타일링; 런 높이가 각 복사본을 따라감
  Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
  Writeln(Sheet.RowHeight[7]:0:2);        // 런 높이
end;

읽기 쪽 경계도 같은 의무를 집니다. GetUsedRange는 아래 모서리를 각 런의 FirstRow와 LastRow까지 밀어 올리고, BuildRowMajorCellOrder는 메타데이터를 포함한 최대 행을 모든 런까지 확장해 XLSX 작성기가 높이만 있는 행도 계속 방문하게 합니다. HotXLS 객체 모델 위에 행 키 기반 구조를 직접 얹는다면 점검 목록은 이것입니다. 삽입, 삭제, 타일링, 복사, 사용 범위, 그리고 모든 직렬화기입니다. 하나를 빠뜨리면 실패는 조용합니다. 높이가 삽입 수만큼 어긋나고, 아무것도 예외를 던지지 않습니다

행별로 남는 것과 지금 숫자

숨김 플래그와 개요 수준, 접힘 상태는 여전히 확장됩니다. 행 마무리 루프가 반복 행마다 SetRowHidden과 SetRowOutlineLevel을 한 번씩 돌기 때문에, 백만 행짜리 꼬리를 숨기거나 그것을 table:table-row-group 안에 중첩한 시트는 그 속성들마다 행별 항목을 냅니다. 2.382.2의 변경은 HXLS-005가 실제로 측정한 두 가지 — 높이와 물려받은 빈 스타일 — 로 범위가 잡혀 있고, 어떤 파일이 요구한다면 같은 런 기법을 나머지에도 적용할 수 있습니다. ODS 리더는 style:use-optimal-row-height에도 반응하지 않습니다. optimal이라고 쓰고 높이를 함께 준 행 스타일은 그 높이로 임포트됩니다

코퍼스 기준으로 conditional-formatting.ods는 이제 열기, 단언, 저장, 다시 열기, 재단언 주기를 Win32에서 0.178초, Win64에서 0.158초에 끝내며, 열기 단계 자체는 0.020초입니다. 예전에는 다 써 버렸던 60초 예산 안입니다. 형식이 흐르는 통합 문서 수준 인터페이스는 ODS 파일 열기와 저장 해설에서, 큰 파일을 다루는 더 넓은 수단들은 대용량 통합 문서 성능에서 설명합니다. 반복과 스타일 속성을 지닌 ODF 행 요소 자체는 ODF 1.3 Part 3 §9.1.4에 규정되어 있습니다

HotXLS는 Excel이나 LibreOffice를 설치하지 않고 네이티브 Delphi와 C++Builder 코드에서 XLS, XLSX, ODS를 읽고 씁니다. 그래서 백만 행 반복은 외부 프로세스에 넘길 일이 아니라 라이브러리가 제대로 모델링해야 할 일이며, HotXLS Delphi 스프레드시트 컴포넌트 페이지에 지원 형식과 RAD Studio 버전이 정리되어 있습니다