기술 문서

Delphi에서 ODS 피벗 테이블 왕복과 네임스페이스 스코프

HotXLS Delphi Excel Component은 v2.382.0부터 ODS 열기와 저장 주기에서 OpenDocument 데이터 파일럿 테이블을 유지합니다. 열 때 content.xml의 <table:data-pilot-tables> 서브트리를 그대로 캡처해 저장 시 재생하는 방식입니다. v2.382.1부터는 조각이 조상이 선언한 모든 XML 네임스페이스 바인딩도 함께 지니므로, 저장된 피벗 정의가 HotXLS뿐 아니라 어떤 소비자에게도 올바른 형식으로 남습니다

두 변경을 강제한 버그는 엄격한 코퍼스 실행에서 나왔습니다. LibreOffice 6.1 개발 빌드가 만든 official-pivot.ods 샘플에는 Sheet1.A2:E30을 읽어 결과를 Sheet1.G6:J18에 놓는 DataPilot1 피벗 하나가 들어 있습니다. HotXLS로 열고 변경 없이 저장한 뒤 출력에서 <table:data-pilot-table> 요소를 세어 보면 들어갈 때 하나, 나올 때 0개이며, Win32와 Win64가 같습니다. 테스트에서 피벗을 건드린 것은 아무것도 없습니다. 첫 탐색 라운드는 셀 상수만 비교해 통과했고, 손실을 드러낸 것은 구조 단언이었습니다. "값이 맞는다"가 왕복 충실도의 약한 정의라는 점을 일깨워 주는 대목입니다

라이브러리 저장 후 ODS 피벗 테이블이 사라지는 이유

HotXLS에 OpenDocument 데이터 파일럿 테이블용 인메모리 모델이 없고, ODS 작성기가 content.xml을 전적으로 모델에서 만들기 때문입니다. 작성기는 자동 스타일과 워크시트마다 하나씩 있는 <table:table>, <table:content-validations>, <table:named-expressions>, <table:database-ranges>를 조립하며, 각각은 통합 문서가 실제로 보유한 객체에서 생성됩니다. 피벗 정의 — ODF 1.3 Part 3 §9.6으로, 피벗마다 하나의 <table:data-pilot-table>을 담고 table:source-cell-range, table:data-pilot-field 자식들, table:target-range-address, table:buttons를 지니는 <table:data-pilot-tables> 컨테이너 — 는 들어갈 객체가 없으므로 다시 생성된 파트가 그냥 생략합니다

XLSX와의 대비는 의도적입니다. HotXLS는 SpreadsheetML 피벗 캐시와 피벗 테이블을 실제 모델로 파싱하므로 Delphi에서 만들고 계산 필드로 확장하고 새로 고칠 수 있고, 그래서 그것들은 복사되는 것이 아니라 다시 쓰이면서 저장을 넘깁니다. ODS 피벗은 요청 자체가 훨씬 드물고, 왕복만을 위해 ODF 데이터 파일럿 어휘를 모델링하는 것은 아무도 편집하지 않을 많은 코드가 됩니다. 실용적인 답은 HotXLS가 이미 XLSX의 알 수 없는 extLst 블록에 적용하는 것과 같습니다. 모델링하지 않는 것은 가능하면 바이트 단위로, 안 되면 이벤트 단위로 유지합니다

첫 Pos 기반 캡처가 틀린 부분은 무엇이었을까

v2.382.0의 캡처는 content.xml에서 피벗 정의를 평범한 문자열로 잘라 냈고, 그 조각에는 정의를 의미 있게 만드는 네임스페이스 선언이 빠져 있었습니다. 구현은 들리는 대로 짧았습니다. 파트를 WideString으로 디코드하고, Pos로 여는 태그를 찾고, 그 뒤에서 닫는 태그를 찾아 그 구간을 통합 문서의 FRawOdsDataPilotTablesXml에 복사합니다

// HotXLS v2.382.0 -- 한 릴리스 뒤 대체됨
function OdsCaptureDataPilotTablesXml(Stream: TStream): WideString;
const
  OpenTag: WideString = '<table:data-pilot-tables';
  CloseTag: WideString = '</table:data-pilot-tables>';
var
  Text: WideString;
  StartPos, ClosePos: Integer;
begin
  Result := '';
  Text := LoadPartAsWideString(Stream);   // content.xml 전체를 메모리에
  StartPos := Pos(OpenTag, Text);
  if StartPos = 0 then Exit;
  ClosePos := Pos(CloseTag, Copy(Text, StartPos, MaxInt));
  if ClosePos = 0 then Exit;
  Result := Copy(Text, StartPos, ClosePos + Length(CloseTag) - 1);
end;

개수 단언은 통과했고 수정은 나갔습니다. 그것을 잡아낸 것은 같은 날 추가된 두 번째의 더 엄격한 검사였습니다. 저장된 패키지의 모든 XML 파트를 HotXLS 밖의 독립적인 네임스페이스 인식 파서에 넣는데, 그 파서가 새 content.xml을 unbound prefix 오류로 거부했습니다. LibreOffice의 피벗은 생산자 확장 속성을 지니고 있었습니다. 페이지 필드의 loext:ignore-selected-page="true", 모든 수준의 calcext:repeat-item-labels="false" 같은 것들인데, 잘려 나온 문자열에는 그 속성들이 있었지만 그것을 묶는 xmlns:loext와 xmlns:calcext 선언은 없었습니다. 그 선언들은 소스 파일의 <office:document-content> 루트에 서른다섯 개가 있었고, 피벗에서 2000자 떨어져 있었습니다

W3C Namespaces in XML 1.0 §6.1이 이 문제를 외형상의 문제가 아니라 확실한 실패로 만드는 규칙을 정의합니다. 네임스페이스 선언은 그것이 나타난 요소의 시작 태그부터 그 요소의 끝 태그까지 스코프에 있고, 그 스코프 안의 모든 접두사 이름은 그 선언에 대해 해석됩니다. 문서에서 서브트리를 잘라 내면 그 스코프에서도 잘라 내는 것입니다. HotXLS는 자체 <office:document-content> 루트를 열한 개의 선언 — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — 으로 쓰므로 calcext:는 우연히 해석되고 table:도 우연히 해석되지만 loext:는 해석되지 않았습니다. 네임스페이스 인식 파서는 묶이지 않은 접두사를 형식 위반으로 취급합니다. 즉 속성 하나만 못 읽는 것이 아니라 파트 전체를 읽을 수 없습니다

HotXLS의 official-pivot.ods에 대한 Pos 기반 캡처가 놓친 것: 피벗 서브트리는 loext와 calcext 확장 속성을 지니는데 그것을 묶는 xmlns 선언은 서른다섯 개 바인딩 떨어진 office:document-content 루트에 있어서, 잘려 나온 조각이 사용하는 모든 접두사가 묶이지 않은 채 남았고 네임스페이스 인식 파서가 content.xml 전체를 거부했습니다
네임스페이스 선언은 시작 태그부터 끝 태그까지 스코프에 있으며, 문서에서 서브트리를 잘라 내면 그 스코프에서도 잘라 내게 되어 속성 하나가 읽을 수 없는 파트로 바뀝니다

HotXLS는 조상 xmlns 바인딩을 조각으로 어떻게 옮길까

HotXLS v2.382.1은 문자열 슬라이스를 자체 스트리밍 TXMLReader로 content.xml을 훑는 방식으로 교체했습니다. 각 바인딩이 선언된 깊이를 표시한 네임스페이스 바인딩 스택을 유지하고, 대상에 도달하는 순간 아직 유효한 바인딩을 조각의 루트 요소에 복사합니다. 리더는 PreserveWhitespaceText를 켜고 실행되어 텍스트 노드가 쓰인 그대로 돌아오며, 다시 만든 태그는 리더가 보통 파트 파서에 넘기는 정규 이름이 아니라 파일의 접두사 표기를 담은 TXMLReader.RawName과 TXMLReader.Attribute[I].RawName을 사용합니다. 루프의 핵심은 다음과 같습니다

HotXLS v2.382.1이 데이터 파일럿 서브트리를 네임스페이스 스코프와 함께 캡처하는 방식: 스트리밍 TXMLReader 패스가 선언 깊이를 표시한 xmlns 바인딩 스택을 유지하고, table:data-pilot-tables 대상에서 안쪽부터 바깥으로 훑으며, Seen 집합으로 섀도잉을 존중하고, 요소가 스스로 선언한 접두사는 건너뛰며, 끝 태그와 빈 요소 모두에서 바인딩을 팝합니다
정규 리더 이름으로 대상을 찾으면 table 접두사를 다르게 쓰는 생산자도 계속 동작하며, 닫히지 않는 서브트리는 절반짜리 조각을 저장 시 되쓰는 대신 예외를 던집니다
// Namespaces: 'xmlns:p=uri'를 담는 TStringList, 선언 깊이는 Objects[]에
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // 요소, 텍스트, CDATA, 주석
  if Reader.NodeType = xmlntElement then
  begin
    for I := 0 to Reader.AttributeCount - 1 do
    begin
      AttrName := Reader.Attribute[I].RawName;
      if (AttrName = 'xmlns') or (Pos(WideString('xmlns:'), AttrName) = 1) then
        Namespaces.AddObject(String(AttrName) + '=' + String(Reader.Attribute[I].Value),
          TObject(NativeInt(Depth)));
    end;
    if (CaptureDepth < 0) and (Reader.Name = 'table:data-pilot-tables') then
    begin
      Opening := XlsxRawXmlReaderOpenTag(Reader);   // 뒤쪽 '>' 또는 '/>'를 먼저 제거
      ...
      // 유효한 조상 바인딩을 조각 루트로 옮김
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // 가장 안쪽 바인딩이 우선
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // 여기서 이미 선언되었으면 건너뜀
          Opening := Opening + ' ' + AttrName + '="' +
            XlsxEscapeAttr(WideString(Namespaces.ValueFromIndex[I])) + '"';
      end;
      ...
      CaptureDepth := Depth;
    end;
    if not Reader.IsEmptyElement then Inc(Depth);
  end
  else if Reader.NodeType = xmlntEndElement then
  begin
    Dec(Depth);
    if Depth = CaptureDepth then Exit;                           // 서브트리 닫힘
  end;
  if (Reader.NodeType = xmlntEndElement) or
     ((Reader.NodeType = xmlntElement) and Reader.IsEmptyElement) then
    while (Namespaces.Count > 0) and
          (NativeInt(Namespaces.Objects[Namespaces.Count - 1]) >= Depth) do
      Namespaces.Delete(Namespaces.Count - 1);                   // 스코프를 벗어남
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

그 루프에서 정확성을 담보하는 세부는 세 가지입니다. 스택을 가장 안쪽 바인딩부터 바깥으로 훑고 각 접두사를 Seen에 기억하는 것이 섀도잉을 구현합니다. 더 가까운 조상이 xmlns:table을 다시 묶으면 가까운 값이 이기며, §6.1이 요구하는 대로입니다. 요소가 이미 스스로 선언한 접두사를 건너뛰면 같은 속성을 두 번 내보내지 않는데, 그것은 또 다른 형식 위반이 됩니다. 그리고 팝 규칙은 끝 태그와 빈 요소 모두에서 발동합니다. <x/>는 EndElement 이벤트를 만들지 않기 때문이며, XLSX extLst 캡처가 배워야 했던 것과 같은 자기 닫힘 함정입니다. 대상을 RawName이 아니라 Reader.Name으로 찾는 것은 더 조용한 이점입니다. 리더가 ODF table 네임스페이스 URI를 table 접두사로 정규화하므로 t:data-pilot-tables로 쓰는 생산자도 여전히 매칭되고, 내보낸 조각은 생산자가 쓴 접두사를 그대로 유지합니다

이 루프는 추측도 거부합니다. 캡처가 아직 열려 있는데 파트가 끝나면 — 잘렸거나 잘못된 content.xml — OdsCaptureDataPilotTablesXml은 절반짜리 조각을 반환하는 대신 예외를 던집니다. 절반짜리 조각은 저장 시 되쓰여 손상된 입력을 라이브러리 이름이 붙은 손상된 출력으로 바꾸기 때문입니다

조각은 저장된 content.xml 어디에 들어갈까

HotXLS는 캡처한 조각을 <office:spreadsheet> 안에서 자기가 생성한 <table:named-expressions> 바로 뒤, <table:database-ranges> 앞에 씁니다. <office:spreadsheet>의 ODF 1.3 Part 3 콘텐츠 모델은 그 뒤쪽 자식들에 고정된 순서를 규정하므로, 그대로 옮긴 블록을 작성기가 마침 있는 자리에 아무렇게나 덧붙일 수는 없습니다. 정해진 슬롯에 넣어야 합니다. 호출자 입장에서는 API도 설정할 것도 없습니다. 정의는 평범한 열기와 저장에 함께 따라갑니다

HotXLS ODS 저장에서 캡처한 피벗 정의가 들어가는 자리: office:spreadsheet의 자식들은 생성된 table 요소부터 table:content-validations와 table:named-expressions까지 고정된 ODF 순서를 따르고, 그대로 옮긴 table:data-pilot-tables 조각이 table:database-ranges 앞 슬롯에 들어가며, 정의가 OpenODS와 SaveAsODS에 함께 따라가므로 API가 존재하지 않습니다
그대로 옮긴 블록은 작성기가 마침 있는 자리에 덧붙일 수 없으며, 그것이 지니는 조상 바인딩 사본은 Namespaces in XML이 중첩 스코프에서 접두사 재선언을 허용하므로 무해합니다
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.OpenODS('official-pivot.ods') <> 1 then
      raise Exception.Create('open failed');
    Book.Sheets[0].Cells[2, 5].Value := 1250.0;   // 피벗 소스 범위 안을 편집
    Book.SaveAsODS('official-pivot-out.ods');
    // 출력의 content.xml에는 여전히 DataPilot1과 그
    // 소스 범위, 필드, 대상 범위, 버튼, loext:/calcext: 속성이 남음
  finally
    Book.Free;
  end;
end;

이 중복은 의도적이며 알아 둘 가치가 있습니다. 이제 조각 루트는 저장된 문서 루트도 선언하는 xmlns:table과 xmlns:calcext를 다시 반복합니다. Namespaces in XML은 중첩 스코프에서 접두사 재선언을 허용하므로 중복은 무해합니다. LibreOffice 샘플의 경우 실어 나른 집합은 루트 선언 서른다섯 개 전부로, 8357자 정의 위에 약 2킬로바이트가 더해집니다. 캡처가 서브트리가 실제로 쓰는 접두사를 분석하지 않기 때문입니다. 사용 접두사 스캔을 하면 줄일 수 있고 나중에 올 수도 있습니다. 정확성이 먼저고 압축은 그다음입니다

그대로 재생을 위해 XML에서 서브트리를 잘라 내는 규칙

일반적인 교훈은 서브트리는 스스로 그렇게 만들어야만 자기완결적이 된다는 것이고, 잊었을 때 가장 먼저 깨지는 것이 네임스페이스 스코프입니다. 이제 HotXLS가 "모델링하지 않는 것은 유지한다" 캡처에 적용하는 점검 목록입니다

  • 실제 리더로 문서를 훑고 스코프에 있는 바인딩을 추적하십시오. Pos를 쓰는 문자열 검색은 스코프를 전혀 보지 못하며, 같은 이름의 중첩 요소나 주석, CDATA 구역 안의 일치 문자열, 태그 텍스트를 우연히 담은 속성 값에서도 잘못 짚습니다
  • 유효한 바인딩을 조각 루트에 복사하되 가장 안쪽부터, 접두사마다 한 번씩, 루트가 이미 선언한 것은 건너뛰십시오
  • 내보내는 태그에는 원래 접두사 표기를 유지하고, 대상은 리터럴 접두사가 아니라 해석된 네임스페이스로 찾으십시오
  • 공백 텍스트 노드를 보존하고, 빈 요소가 끝 태그 이벤트 없이 자기 스코프를 닫는다는 점을 기억하십시오
  • 저장된 파트를 테스트 대상 라이브러리가 아닌 파서로 검증하십시오. 라이브러리는 자기가 쓴 것과 같은 관대한 코드 경로로 자기 출력을 기꺼이 다시 읽습니다

마지막 항목이 실제로 HXLS-003을 두 번째로 찾아낸 것입니다. v2.382.0의 인수 검사는 저장된 content.xml에서 data-pilot-table 시작 태그를 세는 정규식이었고, 정규식은 문서가 아니라 태그를 보므로 그 태그의 접두사가 묶였는지에 눈이 먼 상태입니다. v2.382.1에서 추가된 엄격한 코퍼스 러너는 저장된 패키지의 모든 XML과 .rels 파트를 네임스페이스 인식 파서로 파싱한 뒤, 피벗 트리 — 태그, 정렬된 속성, 텍스트, 자식 — 를 원본과 재귀적으로 비교합니다. 그 비교는 네임스페이스를 확장한 상태로 하므로 접두사 표기가 달라져도 통과하고, 묶이지 않은 접두사는 통과할 수 없습니다

그대로 보장이 끝나는 지점

그대로 재생은 정의를 보존하는 것이지 이해하는 것이 아니며, 경계는 거기서 따라옵니다. HotXLS는 ODS 피벗을 읽거나 편집하거나 새로 고치는 API를 노출하지 않으므로 FRawOdsDataPilotTablesXml은 내부 필드이고, 관찰 가능한 유일한 동작은 정의가 살아남는다는 것입니다. 조각은 바이트로 복사되지 않고 리더 이벤트에서 다시 직렬화됩니다. 속성 따옴표와 자기 닫힘 형식은 정규화되고 텍스트와 공백은 유지됩니다. 캡처한 XML은 ODS 콘텐츠 작성기에서만 나가므로, .ods에서 열어 .xlsx로 저장한 통합 문서는 피벗을 잃고 .xlsx에서 연 통합 문서는 .ods 저장에 재생할 것이 없습니다. ODS 임포트와 익스포트 경로의 비대칭이 여기에도 그대로 적용됩니다. 그리고 정의가 불투명하므로 편집을 따라갈 수 없습니다. HotXLS에서 Sheet1의 이름을 바꾸거나 소스 데이터를 옮기면 저장된 피벗은 여전히 Sheet1.A2:E30을 가리키고, 다음 새로 고침 때 소비자가 깨진 범위를 보고하게 됩니다. 순서에 관한 주의도 하나 여기 해당합니다. HotXLS는 AutoFilter 범위를 피벗 조각 뒤에 <table:database-ranges>로 내보내는데 코퍼스 샘플에는 데이터베이스 범위가 없습니다. 따라서 필터와 피벗을 둘 다 가진 통합 문서는 그 두 요소의 상대 순서에 의존하기 전에 ODF 스키마 검증기를 거쳐 보는 것이 좋습니다

코퍼스 샘플만이 아니라 자기 생산자의 파일로도 테스트하십시오. 네임스페이스 이월은 생산자가 조상에 선언한 어떤 접두사든 처리하지만, 피벗 요소 자체에 접두사를 선언하거나 테이블 어휘에 기본 네임스페이스를 쓰는 문서는 LibreOffice 샘플이 건드리지 않는 건너뛰기와 섀도잉 분기를 행사합니다. 둘 다 구현되어 있지만 코퍼스에 샘플은 아직 없고, 그 구분이 바로 changelog 항목이 흐리기 쉬운 종류의 것입니다

v2.382.0의 데이터 파일럿 그대로 캡처와 v2.382.1의 네임스페이스 스코프 수정은 현재 HotXLS Delphi Excel Component에 실려 있으며, 제품 페이지에 Delphi와 C++Builder용 ODS, XLSX, XLS 읽기-쓰기 지원 범위가 정리되어 있습니다