기술 문서

Delphi에서 HotXLS 차트 핑거프린트 시점과 앵커 오프셋

HotXLS Delphi Component가 수정되지 않은 Excel 차트를 바이트 단위로 그대로 재생하려면 두 가지 조건이 성립해야 합니다. 차트가 추측한 파트 이름이 아니라 워크시트 드로잉 릴레이션십을 통해 도달한 것이어야 하고, 64비트 모델 핑거프린트가 차트 모델 파싱이 끝난 뒤에 캡처된 것이어야 합니다. 버전 2.382.0은 첫 번째 조건을 고쳤고, 버전 2.382.3은 두 번째를 고치면서 드로잉 작성기가 0으로 하드코딩하던 0이 아닌 xdr:colOff와 xdr:rowOff 앵커 오프셋을 왕복 처리하기 시작했습니다. 두 결함은 모두 로컬 코퍼스의 한 케이스인 two-charts.xlsx에서 나왔습니다. 먼저 구조 검증이 차트 파트가 둘에서 셋으로 늘어난 것을 잡아냈고, 그다음 모든 xl/charts/chartN.xml을 바이트 비교한 결과 아무도 손대지 않은 차트가 여전히 다시 쓰이고 있었습니다. 두 문제 모두 예외를 던지지도, Excel이 불평하게 만들지도 않아서 이렇게 오래 살아남았습니다

차트 두 개짜리 통합 문서가 왜 차트 파트 세 개로 돌아왔을까

로더에 추측하는 폴백이 있었기 때문입니다. 워크시트의 .rels 파트에 드로잉 릴레이션십이 없으면 예전 코드는 드로잉이 관례적인 이름 xl/drawings/drawing{i+1}.xml에 있다고 가정했습니다. 여기서 i는 시트 위치이고, 그 파트가 아카이브에 존재하면 붙였습니다. two-charts.xlsx에서 첫 번째 시트는 드로잉도 .rels 파트도 없는데 xl/drawings/drawing1.xml은 존재합니다. 이것은 두 번째 시트의 것으로, 그 시트가 Target="../drawings/drawing1.xml"을 통해 도달합니다. 그래서 시트 1은 참조한 적도 없는 차트를 물려받았고, chart1.xml은 두 번 파싱되었으며, 저장 시 통합 문서가 차트 파트 두 개가 아니라 세 개로 기록되었습니다

HotXLS가 two-charts 샘플에서 워크시트 드로잉을 해석하는 방식: Sheet1은 드로잉 릴레이션십도 rels 파트도 없지만 Sheet2는 ParPartTargets를 통해 xl/drawings/drawing1.xml에 도달하며, 2.382.0 이전 폴백은 시트 위치로 그 관례적 이름을 추측해서 chart1.xml이 두 번 파싱되고 저장 시 차트 파트 세 개가 기록되었고, 수정 후에는 XlsxRtDrawing을 통해서만 드로잉을 로드합니다
Sheet1은 차트를 참조한 적이 없으므로 릴레이션십 그래프만이 드로잉 대상을 얻는 안전한 출처이며, 관례적 이름을 추측한 탓에 차트 두 개짜리 통합 문서가 파트 세 개로 저장되었습니다

HotXLS v2.382.0의 수정은 추측을 완전히 제거했습니다. 이제 워크시트 드로잉은 해당 시트의 드로잉 릴레이션십 타입에 기록된 대상인 ParPartTargets[i].Values[XlsxRtDrawing]을 통해서만 로드되며, 그런 릴레이션십이 없는 시트는 드로잉을 전혀 갖지 않습니다. 형식이 요구하는 동작이 바로 이것입니다. 워크시트의 <drawing r:id="…"/> 요소(ECMA-376 Part 1 §18.3.1.36)가 시트와 드로잉을 잇는 유일한 연결이며, OPC 패키지의 파트 이름은 릴레이션십 그래프가 부여한 것 이상의 의미를 갖지 않습니다. Excel이 기록한 아카이브가 우연히 관례적 이름을 사용했기 때문에 이 지름길이 그렇게 오래 통했으며, HotXLS의 OPC 릴레이션십 해석 해설에서 추측이 대체로 맞더라도 파트 이름 추측이 왜 결코 안전하지 않은지 다룹니다

// v2.382.0 이전: 드로잉 릴레이션십이 없으면 추측으로 폴백
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
  drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);   // 다른 시트의 것일 수 있음

// v2.382.0 이후: 릴레이션십이 없으면 아무것도 하지 않음
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);

차트 핑거프린트가 보장하는 것

핑거프린트는 차트마다 저장 시 원본 파트를 복사할 수 있는지, 아니면 다시 생성해야 하는지를 결정합니다. 임포트할 때 Open 전에 PreserveUnsupportedParts를 켜 두면 HotXLS는 각 차트 파트의 원시 UTF-8 바이트를 FRawChartXml에 보관하고, BuildChartKnownXml로 타입 모델 자체의 직렬화를 만들어 그 길이를 FRawChartModelLength에, 해시를 FRawChartModelHash에 저장합니다. 해시는 생성된 XML의 UTF-16 코드 단위에 대한 FNV-1a이며, 표준 64비트 오프셋 베이시스 14695981039346656037과 소수 1099511628211을 사용합니다. 저장 시점에 XlsxChartRawModelUnchanged가 known XML을 다시 만들어 길이와 해시를 비교하고, 일치하면 타입 모델이 임포트 당시와 정확히 같다는 뜻이므로 애플리케이션이 바꿀 수 있었던 어떤 것도 바뀌지 않은 것입니다

HotXLS가 임포트 시 차트 핑거프린트를 캡처하는 방식: 원시 UTF-8 바이트는 FRawChartXml에 보관하고 BuildChartKnownXml이 FRawChartModelLength와 FNV-1a 해시를 산출하며, 저장 시 XlsxChartRawModelUnchanged가 두 값을 다시 만들어 비교해서 일치하면 원본 바이트를 재생하거나 압축 엔트리를 복사하고 불일치하면 XlsxMergeChartXml로 넘어갑니다
핑거프린트는 캡처한 시점만큼만 정확하며, 모든 복구 패스가 끝나기 전에 캡처하면 완성된 모델과 다시는 일치하지 않는 해시가 됩니다
function XlsxChartRawModelUnchanged(Chart: TXLSXChart;
  const KnownXml: WideString): Boolean;
begin
  Result := (Chart <> nil) and (Chart.FRawChartXml <> '') and
    (Length(KnownXml) = Chart.FRawChartModelLength) and
    (XlsxChartModelHash(KnownXml) = Chart.FRawChartModelHash);
end;

function BuildChartXmlFromKnown(Chart: TXLSXChart;
  const KnownXml: WideString): WideString;
begin
  if Chart.FRawChartXml = '' then
    Result := KnownXml                                  // 보존된 것 없음
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // 그대로 재생
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // 구조 병합
end;

XLSX 작성기는 BuildChartXmlFromKnown보다 한 걸음 더 나아갑니다. 모델이 변경되지 않았고 StrictOOXML이 꺼져 있으면, 먼저 압축된 엔트리를 원본 아카이브에서 출력의 차트 새 파트 이름 아래로 그대로 복사하려 시도하므로 바이트를 디코드해 다시 deflate하지도 않습니다. 그 복사가 불가능할 때만 디코드 아니면 병합 경로로 넘어갑니다. 메커니즘 자체 — 길이와 해시, 같으면 재생, 다르면 병합 — 는 ChartML을 잃지 않고 Excel 차트 편집하기 노트에 설명된 것과 같습니다. 이 글은 그것이 조용히 동작을 멈춘 방식에 관한 것입니다

그런데 왜 모든 차트가 결국 병합 경로를 탔을까

핑거프린트를 한 호출 너무 일찍 캡처했기 때문입니다. HotXLS의 차트 파싱은 차트 파트에 대한 SAX 패스와, SAX 핸들러가 직접 모델링하지 않는 세부 정보를 원시 텍스트에서 끌어내는 일련의 복구 패스로 이루어집니다. XlsxChartParseSeriesFlags는 각 <c:ser> 블록에서 <c:smooth> 플래그와 마커 채우기 및 마커 선의 srgbClr 값을 읽고, 이어서 범주 축과 값 축의 축 교차 모드와 주/보조 눈금 표시 스타일을 복구합니다. v2.382.3 이전에 ParseChartXml 끝부분의 순서는 축 그룹 분류, known XML 작성, 길이와 해시 캡처, 그다음에야 XlsxChartParseSeriesFlags 실행이었습니다. 따라서 핑거프린트는 smooth 플래그와 마커 색상, 눈금 표시가 아직 빠진 모델을 기술했습니다. 저장 시점에 BuildChartKnownXml은 완성된 모델을 대상으로 실행되어 이제 <c:smooth val="1"/>와 복구된 마커 색상을 내보냈습니다. XML이 더 길어지고 해시가 달라져 XlsxChartRawModelUnchanged가 False를 반환했으며, 차트는 XlsxMergeChartXml로 갔습니다. 병합은 누군가 편집한 차트에는 올바른 연산이지만 바이트를 보존하는 연산은 아닙니다. 트리를 다시 직렬화하고, 시리즈와 축, 플롯 그룹에 대해 타입 모델이 이기게 하는 소유권 규칙 때문에 재생성된 노드가 원본을 대체합니다. 코퍼스 실행에서 눈에 보인 결과는 아무도 편집하지 않은 차트의 시리즈 색상이 어긋나는 것이었습니다. 보존된 모든 통합 문서의 모든 차트가, 매 저장마다, 진단 하나 없이 말입니다

수정은 순서를 한 번 바꾸는 것입니다. 이제 XlsxChartParseSeriesFlags가 known XML을 만들기 전에 실행되므로, 핑거프린트는 애플리케이션이 처음 보는 시점의 모델을 기술합니다. 교훈은 차트를 넘어 일반화됩니다. 변경 감지 핑거프린트는 그것을 캡처한 시점만큼만 정확하고, 안전한 시점은 모델을 변경할 수 있는 모든 패스가 끝난 뒤입니다. HotXLS에는 같은 두 값에 대한 두 번째 캡처 지점이 있습니다. 저장에 성공한 뒤 출력 파일을 기준으로 다시 세우는 베이스라인인데, 그 지점은 항상 완전히 파싱된 모델을 대상으로 실행되었습니다. 임포트 시점의 지점만 유별났던 것입니다

앵커 오프셋은 어디로 사라졌을까

말 그대로 0이 되었습니다. 드로잉 파트의 twoCellAnchor는 차트를 두 셀 사이에 고정하고, 각 모서리는 셀 인덱스와 그 셀 안의 오프셋을 함께 지닙니다. from(ECMA-376 Part 1 §20.5.2.5)과 to(§20.5.2.32)가 각각 col, colOff(§20.5.2.4), row, rowOff를 담습니다. 오프셋 단위는 EMU(English Metric Units)로 인치당 914400이며, Excel은 마우스로 차트를 배치하거나 크기를 바꿀 때마다 0이 아닌 값을 기록하는데, 대부분의 차트가 그렇습니다. two-charts.xlsx의 첫 번째 차트는 rowOff 19049로 행 0에서 시작해 colOff 247650과 rowOff 66674로 열 8, 행 15에서 끝납니다. 마지막 열 안으로 약 4분의 1인치 들어간 셈입니다. HotXLS의 드로잉 파서는 이 네 값을 늘 읽고 있었고 이미지 코드도 그것을 사용했지만, 차트 작성기는 모든 모서리에 <xdr:colOff>0</xdr:colOff>과 <xdr:rowOff>0</xdr:rowOff>를 내보내 저장할 때마다 차트를 셀 격자에 스냅시켰습니다

HotXLS 샘플 첫 번째 차트의 xdr:twoCellAnchor 모서리 구조: from은 col 0과 rowOff 19049를 담고 to는 col 8, colOff 247650, rowOff 66674를 인치당 914400 EMU로 담으며, 0 오프셋을 내보내던 작성기는 FFromColOff와 FToColOff 및 그 형제들이 임포트한 값을 재생하기 전까지 차트를 격자에 스냅시켰습니다
앵커는 차트 파트가 아니라 드로잉 파트에 있으므로 이 수정은 핑거프린트 수정과 별개이며, 통합 문서가 진짜로 왕복되려면 둘 다 나가야 했습니다
// v2.382.3부터 앵커 작성기는 임포트한 EMU 오프셋을 재생
Result := '<xdr:twoCellAnchor' + EditAsAttr + '><xdr:from><xdr:col>' +
  IntToStr(Chart.FromCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FFromColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.FromRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FFromRowOff) + '</xdr:rowOff></xdr:from>' +
  '<xdr:to><xdr:col>' + IntToStr(Chart.ToCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FToColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.ToRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FToRowOff) + '</xdr:rowOff></xdr:to>' + ...

TXLSXChart는 이제 FFromColOff, FFromRowOff, FToColOff, FToRowOff를 지니며, 드로잉 파서에서 채워지고 차트가 할당될 때 다른 앵커 상태와 함께 복사됩니다. 이들은 의도적으로 private입니다. 공개 앵커 표면은 여전히 네 개의 셀 좌표 FromRow, FromCol, ToRow, ToCol이고, Delphi 코드에서 만든 차트는 예전처럼 셀 경계에 놓입니다. 오프셋은 왕복을 충실하게 만들기 위해 존재하는 것이지, 셀 이하 위치 지정을 기능으로 노출하려는 것이 아닙니다. 이 수정은 핑거프린트와 무관합니다. 앵커는 차트 파트가 아니라 드로잉 파트에 있으므로, ChartML이 완벽하게 재생된 차트라도 이 수정이 없으면 격자로 튀었을 것입니다. 그 EMU 값 뒤의 단위 변환은 HotXLS 이미지 지오메트리와 EMU 스케일링 노트에서 다룹니다

차트가 변경 없이 왕복했음을 어떻게 증명할까

Excel에서 결과를 열어 보는 것이 아니라 바이트를 비교하는 것입니다. Excel은 로드할 때 너무 많이 복구하고 정규화하기 때문에, 어긋난 차트도 분석가가 마커 색이 바뀐 것을 알아채기 전까지는 멀쩡해 보입니다. 두 결함을 잡아낸 코퍼스 테스트는 편집 없이 열고 저장한 뒤 세 가지를 합니다. 워크시트와 드로잉, 차트 릴레이션십을 순회하며 중복되거나 고아가 되거나 끊어진 차트 참조가 있으면 실패시키고, 원본과 출력 사이에서 차트 타입과 시리즈 수식, 앵커 지오메트리의 시그니처를 비교하며, two-charts.xlsx에 대해서는 두 아카이브에서 각 xl/charts/chartN.xml을 읽어 바이트가 동일할 것을 요구합니다. 같은 검사는 RTL TZipFile로 Delphi에서 작성하기 쉽습니다

uses System.Zip, System.SysUtils;

function ChartPartsIdentical(const Original, Resaved: string): Boolean;
var
  Src, Dst: TZipFile;
  Name: string;
  A, B: TBytes;
begin
  Result := True;
  Src := TZipFile.Create;
  Dst := TZipFile.Create;
  try
    Src.Open(Original, zmRead);
    Dst.Open(Resaved, zmRead);
    for Name in Src.FileNames do
      if Name.StartsWith('xl/charts/chart') and Name.EndsWith('.xml') then
      begin
        Src.Read(Name, A);
        Dst.Read(Name, B);   // 파트가 사라졌으면 예외 발생
        if (Length(A) <> Length(B)) or
           ((Length(A) > 0) and not CompareMem(@A[0], @B[0], Length(A))) then
        begin
          Writeln('changed: ', Name);
          Result := False;
        end;
      end;
  finally
    Dst.Free;
    Src.Free;
  end;
end;

그 비교가 의미를 가지려면 세 가지 조건이 필요하고, 잊으면 각각 조용히 실패합니다. PreserveUnsupportedParts가 Open 전에 True여야 합니다. 그렇지 않으면 원시 바이트가 캡처되지 않아 모든 차트가 모델에서 다시 만들어집니다. StrictOOXML은 False여야 합니다. strict 모드는 설계상 재생성을 강제하기 때문입니다. 그리고 애플리케이션이 열기와 저장 사이에 차트를 건드리지 않아야 합니다. 속성을 읽는 것은 괜찮지만 타입 모델을 바꾸는 setter는 핑거프린트를 뒤집어 차트를 병합 경로로 보내는데, 이는 올바른 동작이며 이 테스트의 목적이 아닙니다. 차트 파트는 저장 시 통합 문서 전역 카운터로 번호가 다시 매겨지므로, 시트 순서나 차트 순서가 바뀐 통합 문서는 동일한 바이트를 다른 chartN.xml 이름 아래에 놓게 됩니다. 코퍼스 검사기가 이름이 아니라 릴레이션십을 따라가는 이유입니다

두 수정은 HotXLS 2.382.0과 2.382.3에 실려 나갔고 Win32와 Win64에서 로컬 코퍼스를 대상으로 검증되었으며, 다시 저장한 차트 샘플은 독립적인 오피스 스위트를 통해 PDF로 렌더링해 원본과 페이지별로 비교했습니다. HotXLS는 Excel 설치 없이 네이티브 Delphi와 C++Builder 코드에서 XLSX 차트를 읽고 편집하고 씁니다. 그래서 이 수준의 충실도가 라이브러리의 책임이 되는 것이며, HotXLS Delphi 스프레드시트 컴포넌트 페이지에 기능 목록과 트라이얼 다운로드가 있습니다