HotXLS는 pivotField와 cacheField 요소가 ECMA-376 Part 1 §18.10 스키마로 검증되는 XLSX 피벗 테이블 정의를 기록합니다. axis 속성은 ST_Axis 토큰인 axisRow, axisCol, axisPage를 쓰고, 값 영역 필드는 dataField="1"을 달며, item 목록은 절대 비어 있지 않고, 캐시 필드는 숫자 numFmtId를 저장합니다. v2.384.33부터 리더도 예전에 잘못 읽던 스키마 기본값을 존중합니다
이 정리의 배후에 있는 버그들은 안쓰러운 공통점이 있습니다. 하나도 테스트를 떨어뜨린 적이 없다는 것입니다. HotXLS가 피벗을 쓰고, HotXLS가 다시 읽고, 모든 필드가 올바른 axis에 착지했으니 왕복 스위트는 수년간 초록불이었습니다. 문제는 작성기와 리더가 사설 방언으로 조용히 합의했다는 점입니다. Delphi에서 만든 피벗은 그걸 만든 컴포넌트에게는 멀쩡해 보였지만, CT_PivotField와 CT_CacheField로 검사하니 잘못된 열거 토큰, 스키마가 금지하는 빈 요소, 그리고 Excel이 기대했지만 한 번도 받지 못한 플래그들이 드러났습니다. 서버에서 피벗을 생성해 Excel로 열거나 자기 파서에 먹이는 사람들에게 보낸다면, 유효한 계약은 스키마뿐입니다. 자기 리더가 어디까지 용서해 주는지는 상관없습니다
HotXLS 왕복 전송은 잘못된 axis 토큰을 왜 한 번도 못 잡았을까요?
HotXLS 왕복 전송이 잘못된 axis 토큰을 한 번도 잡지 못한 이유는 리더가 두 표기를 다 받아들였기 때문입니다. 옛 XlsxPivotAxisAttr는 axis="rowAxis", colAxis, pageAxis를 내보냈는데, 영어로는 자연스럽게 읽히지만 스키마에는 존재하지 않습니다. ST_Axis는 정확히 네 값, 즉 axisRow, axisCol, axisPage, axisValues만 정의합니다. 한편 lxPivotXml.pas의 PivotAxisFromToken은 스키마 토큰과 발명된 토큰을 모두 매칭했으니 모든 자가 테스트가 통과했습니다. 작성기는 이제 스키마 토큰만 내보내고, 리더는 예전 HotXLS 버전이 저장한 파일이 레이아웃 그대로 로드되도록 옛 표기를 계속 받아들입니다
<!-- v2.384.33 이전: 잘못된 ST_Axis 값, 빈 CT_Items -->
<pivotField axis="rowAxis" defaultSubtotal="1"><items count="0"></items></pivotField>
<!-- v2.384.33부터 -->
<pivotField axis="axisRow" defaultSubtotal="1">
<items count="4"><item x="0"/><item x="1"/><item x="2" h="1"/><item t="default"/></items>
</pivotField>
옛 작성기가 건너뛴 것 중 CT_PivotField가 요구하는 것은?
CT_PivotField가 요구하는데 옛 BuildPivotTableXml이 빠뜨리거나 틀렸던 것은 셋입니다. 첫째, 값 영역에서 집계되는 필드는 자기 정의에 dataField="1"을 명시해야 합니다. 작성기는 이제 DataFields의 항목이 참조하는 모든 필드에 이 플래그를 설정하며, <dataFields> 목록에만 국한하지 않습니다. 둘째, CT_Items는 최소한의 item을 필요로 하므로, item 없는 필드는 더 이상 빈 <items count="0">을 받지 않고 요소 전체를 생략합니다. 셋째, 각 item은 상태를 유지합니다. 숨긴 item의 h="1"(TXLSPivotItem.IsHidden)과 접힌 상세의 sd="0"(IsDetailHidden)인데, 옛 작성기는 매 저장마다 이 둘을 드랍했습니다
미묘한 부분은 꼬리 소계 item들입니다. 필드에 item이 있으면 Excel은 데이터 item 뒤에 소계 함수당 item을 하나씩 더 나열하고, ST_ItemType으로 타이핑합니다. 자동 소계는 <item t="default"/>이고, 명시적 소계는 sum, countA, avg, max, min, product, count, stdDev, stdDevP, var, varP입니다. HotXLS는 저장 시점에 TXLSPivotField.Subtotals에서 이 항목들을 유도하고 items count에 셉니다. AddPivotTable이 만드는 필드는 빈 Subtotals 집합으로 시작하므로 defaultSubtotal="0"과 꼬리 item 없이 기록됩니다. 리포트에 소계가 필요하면 명시적으로 요청하세요. 명명 함정도 주의하세요. xlpsCount는 countA(모든 항목)에 매핑되고 xlpsCountNums는 count(숫자만)에 매핑됩니다
uses
lxHandleX, lxPivot;
var
Book : TXLSXWorkbook;
Sheet : TXLSXWorksheet;
Pivot : TXLSPivotTable;
Region: TXLSPivotField;
begin
Book := TXLSXWorkbook.Create;
try
Book.Open('orders.xlsx');
Sheet := Book.Sheets[1]; // 1 기반, XLS 엔진과 동일
Pivot := Sheet.AddPivotTable('Data!$A$1:$D$800', 3, 6, 'RegionTotals');
if Pivot = nil then
raise Exception.Create('Bad source range or anchor');
Region := Pivot.AddRowField('Region'); // 그런 필드가 없으면 nil
if Region <> nil then
Region.Subtotals := [xlpsDefault, xlpsAverage]; // -> t="default", t="avg"
Pivot.AddColumnField('Quarter');
Pivot.AddDataFieldByName('Revenue', xlpaSum); // Revenue에 dataField="1" 플래그
Book.SaveAs('orders-pivot.xlsx');
finally
Book.Free;
end;
end;
HotXLS는 이제 소계 item과 스키마 기본값을 어떻게 읽을까요?
HotXLS 리더는 이제 t 속성이 존재하고 data가 아닌 item은 건너뜁니다. 소계, 총합계, 빈 항목은 캐시 인덱스를 실지 않기 때문입니다. v2.384.34 전에는 이 항목들이 CacheItemIndex를 -1로 set한 평범한 item으로 로드되어, Excel이 만든 피벗이 아무데도 가리키지 않는 유령 멤버와 함께 돌아왔고, Items를 걷는 코드는 손으로 걸러야 했습니다. 작성기가 꼬리 항목들을 Subtotals에서 다시 만드므로, 리더의 일은 이들을 그 집합으로 번역하는 것이지 데이터로 유지하는 게 아닙니다
두 번째 리더 수정은 존재하지 않는 속성에 관한 것입니다. 스키마에서 CT_PivotField의 defaultSubtotal과 CT_SharedItems의 containsString은 모두 true가 기본값이고, Excel은 그 기본값을 담을 때 속성을 생략합니다. HotXLS는 예전에 없는 속성을 false로 읽었으니, Excel이 저장한 모든 피벗이 로드 때 조용히 기본 소계를 잃었고, 평범한 텍스트 캐시 필드는 문자열 대신 혼합으로 분류됐습니다. 이는 axis 버그의 거울상입니다. 모든 속성을 늘 다 적어 놓는 작성기는 기본값 경로를 한 번도 연습하지 않으므로, 다른 생산자의 파일만이 그 경로를 드러냅니다
캐시 필드에서 numFmtId="General"이 잘못된 이유는?
numFmtId="General"이 잘못된 이유는 ST_NumFmtId가 부호 없는 정수이지 형식 이름이 아니기 때문입니다. 옛 캐시 작성기는 모든 cacheField에 그 문자열을 하드코딩했는데, 사용자가 셀 서식 대화상자에서 보는 이름을 빌려 온 것입니다. HotXLS는 이제 캐시 필드의 NumberFormat을 숫자로 기록합니다. 뭔가 설정하지 않았다면 0, 즉 내장 General 형식입니다. 스키마에서 속성 타입을 강제하는 엄격한 파서는 옛 값을 그대로 거부하고, 그게 바로 복구 대화상자로 변질되는 실패 클래스입니다. Excel 복구 프롬프트의 배후에 있는 OPC와 마크업 규칙 글이 그 대화상자가 어떻게 트리거되는지 다룹니다
행 65535 이하의 피벗 테이블이 잘려 나간 이유는?
행 65536 이하에 놓인 XLSX 피벗 테이블이 잘려 나간 이유는 공용 피벗 모델이 FirstRow, LastRow, FirstHeaderRow, FirstDataRow와 열 짝들을 Word로 저장했고, 행 이동 코드가 Min(.., High(Word))으로 잘라 놓았기 때문입니다. 16비트면 충분했던 BIFF8 SxView 레코드의 잔재인데, XLSX 시트는 1,048,576행까지 갑니다. v2.384.37부터 TXLSPivotTable의 이 속성들은 Integer이고, 클램프는 사라졌으며, 값을 좁히는 것은 BIFF8 작성기뿐입니다. TXLSXWorksheet.AddPivotTable과 AddPivotTableCopy는 이제 1..1048576 × 1..16384 밖의 앵커나, 범위가 그리드를 벗어나는 복사에 대해 nil을 반환합니다
var
Pivot: TXLSPivotTable;
Check: TXLSXWorkbook;
begin
// 행 70001은 예전에 16비트 범위로 wrap됐습니다. 이제는 저장과 로드를 살아남습니다
Pivot := Sheet.AddPivotTable('Data!$A$1:$D$800', 70001, 1, 'LateTotals');
if Pivot = nil then
Exit; // 시트 밖 앵커 또는 해석 불가능한 원본 범위
Pivot.AddRowField('Region');
Pivot.AddDataFieldByName('Revenue', xlpaSum);
Book.SaveAs('late.xlsx');
Check := TXLSXWorkbook.Create;
try
Check.Open('late.xlsx');
Pivot := Check.Sheets[1].PivotTables.FindByName('LateTotals');
Assert((Pivot <> nil) and (Pivot.FirstRow = 70001));
finally
Check.Free;
end;
end;
클래식 XLS 엔진은 v2.384.38에 짝이 되는 수정을 받았습니다. 이 모델은 raw 0 기반 SxView와 DConRef 값을 저장하고 AddPivotTable 앵커를 그대로 통과시켰는데, 문서와 데모, XLSX 엔진은 모두 Cells[Row, Col] 같은 1 기반 셀을 썼습니다. 이제 두 엔진 모두 모델에 1 기반 위치를 유지하고, BIFF8 리더는 1을 더하고 작성기는 레코드 경계에서 1을 빼므로, (0, 0)으로 앵커하던 코드는 (1, 1)로 옮겨야 합니다. 클래식 AddPivotTable은 이제 1..65536 × 1..256 밖의 앵커에 대해 nil을 반환하며, 새 호출은 예전과 같은 바이트를 씁니다. 레코드 레이아웃 자체는 변하지 않았고 클래식 .xls 피벗 테이블의 배후에 있는 BIFF8 SX 레코드에서 설명합니다
자기 리더가 아니라 스키마로 검증하기
교훈은 피벗을 넘어 일반화됩니다. 관대한 리더는 작성기 위반을 숨기므로, 자기 코드를 거치는 왕복 전송은 일관성은 증명해도 정확성은 증명하지 않습니다. 여기의 모든 버그는 관용적인 쪽과 결함 있는 쪽이 같은 라이브러리에 살았기에 살아남았습니다. 이 부류의 결함을 실제로 잡는 검사는 생성된 파트에 대한 스키마 검증, 기본값 속성이 생략된 Excel 산출 파일을 자기 리더에 통과시켜 보기, 그리고 파싱 결과가 아닌 정확한 토큰을 고정하는 피쳐입니다. 계산 필드로 XLSX 피벗 테이블 만들고 새로 고치기에서 보여 주는 계산 필드, 계산 item, 총합 비율 레이아웃을 포함해 API로 만드는 피벗은 코드 변경 없이 수정된 XML을 받고, Excel 파일에서 로드한 피벗은 수정하기 전까지 원본 파트를 계속 재생합니다
이 수정들은 모두 현재 HotXLS Delphi 스프레드시트 컴포넌트에 실려 나옵니다. 이 컴포넌트는 머신에 Excel이나 COM automation 없이도 Delphi와 C++Builder에서 XLS, XLSX와 피벗 테이블을 읽고 씁니다