피벗 테이블이 하나 들어 있는 ODS 워크북을 라이브러리로 거치고, 소스 셀 하나를 편집한 뒤 저장하면, 저장된 파일은 모든 셀 값은 그대로인데 피벗이 사라진 상태로 열립니다. content.xml이 들어올 때는 <table:data-pilot-table> 하나를 갖고 있었는데 나갈 때는 0개가 된 것입니다. 가장 먼저 떠오르는 수리 방법은 피벗 XML을 문자열로 통째로 복사하는 것이고, 실제로 요소 개수는 복원됩니다. 그런데 그 직후 네임스페이스를 인식하는 파서가 unbound-prefix 오류를 내며 저장된 파트 자체를 거부합니다. 두 실패 모두 근본 원인이 하나입니다. XML 조각은 자신이 의존하는 네임스페이스 선언이 함께 다닐 때만 안전하게 이동합니다
ODS 피벗 정의는 content.xml 어디에 있나
형식이 data pilot이라 부르는 OpenDocument 피벗 테이블은 content.xml에서 시트들 뒤에 저장되는 정의입니다. 그 파트를 자체 객체 모델로 다시 빌드하는 writer는 모델에 그 정의를 둘 자리가 있을 때만 유지합니다. ODF 1.3 Part 3 §9.6이 컨테이너를 규정합니다. <table:data-pilot-tables>가 피벗당 하나의 <table:data-pilot-table>을 담고, 각각은 table:source-cell-range, table:data-pilot-field 자식들, table:target-range-address, table:buttons 목록을 갖습니다. 피벗이 표시하는 결과 셀들은 워크시트 안에 평범한 셀로 들어 있습니다. 그래서 셀, 스타일, validation, named expression, database range만 재생성하는 writer는 화면의 숫자는 같지만 뒤에 피벗이 없는, 완전히 유효한 스프레드시트를 만들어 냅니다
HotXLS Delphi Excel Component는 OpenODS에서 셀을 하나도 파싱하기 전에 content.xml에서 <table:data-pilot-tables> 서브트리 전체를 읽어 낸 뒤, 그 컬렉션을 자체 피벗 모델로 번역을 시도합니다. 모델이 표현하는 평범한 형태, 즉 로컬 소스 범위와 행 필드, 집계 측정값, 소스 데이터와 일치하는 저장 결과를 갖춘 정의는 워크시트의 PivotTables 목록에 TXLSPivotTable로 들어갑니다. XLSX 피벗에서 얻는 것과 같은 객체이며 계산 필드를 추가하고 Delphi에서 refresh할 수 있습니다. 그 형태를 벗어나는 것은 전부 캡처된 XML로 컬렉션에 남고, SaveAsODS가 그대로 기록합니다. 번역기가 모르는 네임스페이스의 속성 하나면 충분합니다. LibreOffice가 저장한 피벗은 loext:와 calcext: 속성을 가질 수 있는데, loext: 속성이 하나 있으면 정의는 캡처 경로로 내려갑니다
이 폴백은 워크북 단위로 전부 아니면 전무입니다. 세 개 중 하나라도 번역 가능한 형태를 벗어나면 세 개 모두 캡처된 XML로 남습니다. 둘로 나누면 content.xml이 하나의 컨테이너에 두 진실 공급원을 갖게 됩니다. 절반은 객체에서 재빌드, 절반은 텍스트에서 리플레이이고, 두 절반이 어긋나면 그대로 사용자 파일에 들어갑니다. 컬렉션을 온전히 유지하는 쪽이 더 안전한 기본값이며, XLSX에서 알 수 없는 extLst 블록에 HotXLS가 적용하는 정책과 같습니다. 모델이 실어 가지 못하는 것은 쓰여진 그대로 보존합니다
잘라 낸 피벗 정의는 왜 저장 파일을 망가뜨릴까?
문자열 검색으로 content.xml에서 잘라 낸 피벗 정의는 접두사에 의미를 부여하던 네임스페이스 선언을 잃고, Namespaces in XML은 선언 없는 접두사를 well-formedness 오류로 봅니다. 문자열 접근법은 짧습니다. 파트를 WideString으로 디코드하고, Pos로 <table:data-pilot-tables를 찾고, 그 뒤에서 맞는 닫는 태그를 찾아 사이를 통째로 복사합니다. 나온 텍스트는 완전해 보입니다. 페이지 필드에 loext:ignore-selected-page="true"가 있고 모든 레벨에 calcext:repeat-item-labels="false"가 있지만, 이 접두사들을 묶는 xmlns:loext와 xmlns:calcext 선언은 문서 루트인 <office:document-content>에 있고, 수천 글자 앞쪽입니다
Namespaces in XML 1.0 §6.1에 따르면 선언은 그것을 실은 시작 태그부터 맞는 끝 태그까지 스코프 안에 있고, 그 안의 모든 접두사 이름이 이에 대해 해석됩니다. 문서에서 서브트리를 잘라 내면 그 스코프에서도 잘려 나갑니다. 조각을 새 문서에 붙이면 각 접두사는 새 루트가 우연히 선언하고 있을 때만 해석됩니다. HotXLS는 자기 writer가 쓰는 네임스페이스로 자체 <office:document-content>를 작성하는데, office, table, text, style, fo, draw, calcext, tableooo, of가 그 목록에 있습니다. 잘라낸 복사본에서 table:은 해석되고, calcext:도 해석되지만 loext:는 해석되지 않습니다. 네임스페이스를 인식하는 파서는 바인딩되지 않은 접두사 하나만으로 파트 전체를 거부하므로, 피벗이 우아하게 저하되지 않습니다. 주변 시트 데이터까지 읽을 수 없게 됩니다
문자열 검색에는 reader 기반 캡처가 만날 일 없는 다른 사각지대도 있습니다. Pos는 주석 안, CDATA 섹션 안, 그 태그 텍스트를 인용하는 속성 값 안에서도 일치합니다. 같은 이름의 중첩 요소를 구분하지 못합니다. 그리고 리터럴 접두사 table:을 찾는데, ODF는 생산자가 table 네임스페이스를 어떤 접두사로든 묶는 것을 허용합니다
HotXLS가 네임스페이스 스코프를 조각에 실어 나르는 방법
HotXLS는 모든 네임스페이스 선언을 그것이 나타난 깊이와 함께 추적하는 스트리밍 XML reader로 피벗 서브트리를 캡처하고, 대상 요소가 도착한 순간 아직 유효한 선언들을 조각의 여는 태그에 복사합니다. 스택은 xmlns나 xmlns:prefix를 선언하는 시작 태그마다 자라고 바인딩을 선언한 요소가 닫히면 줄어듭니다. 빈 요소도 같은 처리가 필요합니다. <x xmlns:p="..."/>는 별도의 끝 태그 이벤트를 만들지 않으므로, pop은 빈 시작 태그에서도 발화해야 합니다. 그렇지 않으면 바인딩이 형제로 새어 나갑니다
대상 지점에서 캡처는 스택을 가장 안쪽 바인딩부터 바깥으로 걸으며 각 접두사를 한 번씩 씁니다. 안쪽부터 바깥으로 걷는 것이 셰도잉을 구현하는 방식입니다. <office:spreadsheet>가 루트가 이미 선언한 접두사를 다시 바인딩했다면, 피벗 지점에서 힘을 갖는 것은 더 가까운 값입니다. §6.1이 요구하는 그대로이고, 이미 쓴 접두사 집합이 바깥 값이 뒤따르지 않게 막습니다. 피벗 요소가 스스로 선언한 접두사는 건너뜁니다. 한 태그에 xmlns:table을 두 번 쓰면 그것은 별개의 well-formedness 오류입니다
대상은 네임스페이스로 맞추므로 t를 ODF table URI에 묶어 <t:data-pilot-tables>라고 쓰는 생산자도 인식되고, 캡처된 태그는 파일의 철자를 그대로 유지합니다. 공백 텍스트 노드는 쓰여진 그대로 돌아옵니다. 속성 인용 방식과 <x/> 대 <x></x> 선택은 정규화됩니다. 조각이 바이트 단위 복사가 아니라 reader 이벤트에서 재직렬화되기 때문입니다. 잘린 파일처럼 피벗 서브트리가 열린 채로 content.xml이 끝나면, 다음 저장에 반쪽짜리 정의가 기록되는 대신 open이 예외로 실패합니다
실어 나르는 집합은 의도적으로 넉넉합니다. 캡처는 서브트리가 쓰든 안 쓰든 스코프 안의 모든 바인딩을 복사하므로, 조각 루트는 저장된 문서 루트가 선언함에도 xmlns:table과 xmlns:calcext를 반복합니다. 중첩 스코프에서 접두사를 다시 선언하는 것은 Namespaces in XML상 합법이고, 루트에 수십 개의 네임스페이스를 선언하는 생산자의 파일은 몇 킬로바이트 늘어납니다. 목록을 가지치려면 요소 이름, 속성 이름, QName을 담는 속성 값에 나오는 모든 접두사를 서브트리에서 훑어야 하고, 한 곳이라도 놓치면 unbound-prefix 실패가 바로 돌아옵니다. 몇 개의 중복 선언이 그 위험보다 훨씬 값싸게 듭니다
피벗을 실은 워크북 열기, 편집, 저장
호출자 쪽에서 켤 스위치는 없습니다. 피벗 정의는 평범한 OpenODS와 SaveAsODS에 함께 탑니다. 워크시트는 1부터 번호가 매겨지고 두 메서드 모두 성공 시 1을 돌려줍니다:
uses
System.SysUtils, lxHandleX;
procedure EditPivotSource(const InFile, OutFile: string);
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
if Book.OpenODS(InFile) <> 1 then
raise Exception.CreateFmt('Cannot open %s', [InFile]);
// E2는 Sheet1.A2:E30 피벗 소스 범위 안에 있음
Book.Sheets[1].Cells[2, 5].Value := 1250.0;
if Book.SaveAsODS(OutFile) <> 1 then
raise Exception.CreateFmt('Cannot save %s', [OutFile]);
finally
Book.Free;
end;
end;
저장된 content.xml에서 피벗 컬렉션은 <office:spreadsheet> 안에서 writer가 생성한 <table:named-expressions> 바로 뒤, <table:database-ranges> 앞에 놓입니다. HotXLS가 database range로 기록하는 AutoFilter와 피벗을 함께 실은 워크북은 이 두 요소가 나란히 놓이는데, 파이프라인에서 엄격한 ODF 스키마 검증기를 돌린다면 이 쌍을 가장 먼저 살펴볼 지점입니다
이 예제의 편집은 소스 값을 바꾸고 피벗의 결과 셀은 그대로 둡니다. 캡처된 정의는 쓰여진 대로 리플레이됩니다. HotXLS는 재계산하지 않고, 파일을 여는 스프레드시트 애플리케이션이 다음 refresh 때 피벗을 다시 계산합니다. 주소도 마찬가지입니다. Sheet1의 이름을 바꾸거나 소스 블록을 옮기면 저장된 정의는 여전히 Sheet1.A2:E30을 가리키고, refresh 때 소비 애플리케이션이 깨진 범위로 보고합니다. 피벗이 이렇게 이동하는 워크북에서는 시트 이름과 소스 블록을 안정적으로 유지하세요
이 피벗은 어느 경로를 탔을까?
가장 빠른 확인은 워크시트의 PivotTables 목록입니다. 번역된 정의는 TXLSPivotTable로 나타나고 캡처된 정의는 나타나지 않습니다. 변환 리포트는 질문의 나머지 절반, 즉 다른 출력 형식에서 피벗에 무슨 일이 일어날지에 답합니다. GetConversionReport는 저장이나 재계산 없이 워크북을 들여다보고, 돌려준 리포트의 소유권은 호출자에게 있습니다:
uses
System.SysUtils, lxHandleX, lxDiagnostics;
function DescribeOdsPivots(Book: TXLSXWorkbook): string;
var
I, Modeled: Integer;
Report: TXLSConversionReport;
begin
Modeled := 0;
for I := 1 to Book.Sheets.Count do // Sheets는 1부터 시작
Inc(Modeled, Book.Sheets[I].PivotTables.Count);
Report := Book.GetConversionReport(xlsConversionOpenXmlWorkbook);
try
for I := 0 to Report.Count - 1 do
if (Report[I].Feature = 'OpenDocumentPivotTables') and
(Report[I].Fidelity = xlsConversionDropped) then
Exit('Captured ODF pivot XML: keep this workbook on .ods');
finally
Report.Free;
end;
if Modeled > 0 then
Result := Format('%d pivot table(s) in the pivot model', [Modeled])
else
Result := 'No pivot definitions';
end;
출력 경로가 중요한 이유는 캡처된 XML이 ODF 어휘이고 ODS writer만 그것을 내보내기 때문입니다. .xlsx로 저장하면 캡처된 컬렉션에 대응하는 SpreadsheetML이 없고, 리포트는 OpenDocumentPivotTables 항목 아래 xlsConversionDropped로 표기합니다. 바이너리 .xlsb writer는 이것이 남아 있는 워크북을 거부합니다. 번역된 피벗은 평범한 모델 객체라 대상 형식의 규칙을 따릅니다. ODS 임포트 경로와 익스포트 경로의 차이는 여기서도 어디서나처럼 적용되며, 피벗을 실은 파일의 안전한 경로는 .ods로 들여 .ods로 내보내는 것입니다
전부 아니면 전무 폴백에서 한 가지 규칙이 더 나옵니다. 워크북은 캡처된 컬렉션을 리플레이하거나 모델에서 피벗을 기록하는 둘 중 하나이고, 하나의 content.xml 안에서 둘을 섞지 않습니다. 캡처된 피벗 XML이 이미 들어 있는 워크북에 AddPivotTable로 피벗을 추가하면 ODS 저장은 생성된 XML을 리플레이된 XML 옆에 이어 붙이는 대신 오류로 멈춥니다
두 번째 파서로 저장된 파트 검증하기
요소 개수만으로는 네임스페이스에 대해 아무것도 증명되지 않습니다. 저장된 content.xml에서 data-pilot-table 시작 태그를 찾는 정규식은 텍스트만 보고, 두 번째 절의 잘라낸 복사본도 그 검사는 통과합니다. 파일을 쓴 라이브러리와 무관한 네임스페이스 인식 파서가 훨씬 엄격한 심판이고, Delphi에는 이미 하나가 들어 있습니다. 아래 함수는 여러분 테스트 스위트에 추가할 검사입니다. TZipFile과 TXMLDocument는 HotXLS가 아니라 Delphi RTL에서 옵니다:
uses
System.SysUtils, System.Classes, System.Zip,
Xml.xmldom, Xml.XMLIntf, Xml.XMLDoc;
// 직접 만든 테스트 헬퍼: .ods 패키지의 content.xml이
// 네임스페이스 인식 DOM 파서에서 로드되면 True
function OdsContentParses(const OdsFile: string; out Error: string): Boolean;
var
Zip: TZipFile;
Bytes: TBytes;
Stream: TBytesStream;
Doc: IXMLDocument;
begin
Error := '';
Zip := TZipFile.Create;
try
Zip.Open(OdsFile, zmRead);
Zip.Read('content.xml', Bytes);
finally
Zip.Free;
end;
Stream := TBytesStream.Create(Bytes);
try
Doc := TXMLDocument.Create(nil);
try
Doc.LoadFromStream(Stream); // 바인딩되지 않은 접두사는 여기서 실패
Result := True;
except
on E: EDOMParseError do
begin
Error := E.Message;
Result := False;
end;
end;
finally
Stream.Free;
end;
end;
코드가 저장하는 모든 .ods에 이 검사를 돌리고, 피벗을 실은 파일이라면 한 걸음 더 나아가 파싱 후 입력과 출력의 피벗 서브트리를 비교하세요. 태그, 정렬된 속성, 텍스트, 자식을 재귀적으로, 이름은 네임스페이스 URI 더하기 로컬 이름으로 확장해서 비교하는 것입니다. 이 비교는 철자를 바꾼 접두사는 통과시키고 바인딩되지 않은 접두사는 잡아냅니다. 바로 무해와 깨짐을 가르는 선입니다. 사용자가 실제로 쓰는 모든 생산자의 파일을 먹여 보세요. table 네임스페이스를 다른 접두사로 묶거나, 피벗 요소 자체에 접두사를 선언하거나, <office:spreadsheet>에서 접두사를 재바인딩하는 문서는 단일 LibreOffice 파일로는 불가능한 방식으로 skip과 셰도잉 규칙을 단련시킵니다
위에서 쓴 ODS reader와 writer, XLSX와 ODS 양쪽의 피벗 모델, 변환 리포트는 모두 HotXLS Delphi Excel Component의 일부입니다. 제품 페이지에서 Delphi와 C++Builder용 XLSX, XLS, ODS 읽기·쓰기 지원 전체를 확인할 수 있습니다