기술 문서

HotXLS에서 ChartML을 잃지 않고 Excel 차트 편집하기

요즘 Excel 빌드가 저장한 차트 파트는 ECMA-376 차트 스키마가 정의한 엘리먼트에서 멈추는 일이 드물다. 차트 스페이스는 보통 mc:AlternateContent 블록으로 시작해 새 독자에게는 c14:style을, 오래된 독자에게는 평범한 c:style을 내밀고, 시리즈마다 c:extLst 안에 c16:uniqueId를 달며, "셀에서 값 가져오기" 데이터 레이블은 또 다른 확장에 산다. 자기가 모델링하는 것만 파싱하고 저장 시점에 파트를 재생성하는 프로그램은 그런 것을 하나도 다시 쓰지 않는다. 제목에서 단어 하나를 고치고 Excel에서 파일을 다시 열면 셀 범위에서 텍스트를 끌어 오던 레이블이 사라져 있다

해법은 저장을 재구축으로 취급하는 것을 멈추는 것이다. 차트가 도착했을 때의 바이트를 간직하고, 모델링된 것이 아무것도 안 움직였으면 그대로 다시 쓰며, 움직였다면 새 엘리먼트를 원본 트리에 병합해 낯선 부분이 Excel이 놓아 둔 자리에 머물게 한다

아무것도 안 바뀌었으면 원본 ChartML을 재생한다

HotXLS는 TXLSXWorkbook.PreserveUnsupportedParts가 True일 때, 곧 갓 만든 워크북의 기본값일 때 정확히 그렇게 한다. 임포트할 때 각 차트는 원본 UTF-8 바이트를 길이와 함께, 그리고 타입 모델이 표현하는 콘텐츠의 64비트 지문과 함께 간직한다. 저장 시점에 HotXLS는 모델링된 파트를 재생성해 길이와 지문을 비교하고, 둘 다 일치하면 저장해 둔 바이트를 그대로 다시 쓴다. 엘리먼트 재정렬도, 공백 흔들림도 없고, 손대지 않은 모든 차트의 UTF-16 사본을 메모리에 둘 필요도 없다

비교가 콘텐츠 위에서 돌기 때문에 속성에 이미 갖고 있던 값을 할당하는 것은 아무것도 바꾸지 않는다. TXLSXChart 세터는 같은 값이면 일찍 빠져나가므로 데이터베이스 행에서 모든 차트 제목을 "새로 고침"하는 루프도 제목이 이미 올바른 차트는 전부 재생한다. 오차 막대와 추세선의 선 서식은 자체 변경 플래그로 추적되고, 그것을 바꾸는 것은 항상 편집으로 친다

Delphi의 HotXLS ChartML 보존 다이어그램: 바뀌지 않은 차트는 원본 바이트를 재생하고 편집된 차트는 타입 모델 노드를 보존된 XML에 병합하며, 모델만으로 재구축하면 extLst와 AlternateContent 가지가 사라진다
HotXLS는 임포트 때 원본 ChartML 바이트를 저장해 두었다가 그대로 재생하거나, 모델이 바뀌었으면 타입 편집을 보존된 트리에 병합한다

TXLSXChart.HasPreservedXml은 무엇을 편집하기 전에 그 차트가 어느 경로에 있는지 알려 준다. 원본 ChartML이 차트에 붙어 있으면 True를 보고하는데, 이것이 재생과 병합 양쪽의 전제 조건이다

var
  Workbook: TXLSXWorkbook;
  Chart: TXLSXChart;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // 기본값 True. 리팩터링을 거쳐도 의도가 살아남도록 명시적으로 설정한다.
    // Open이 상태를 만들기 때문에 Open 전에 자리 잡아야 한다
    Workbook.PreserveUnsupportedParts := True;
    Workbook.Open('quarterly-review.xlsx');

    Chart := Workbook.Sheets[1].Charts[0];   // Sheets[]는 1 기반, Charts[]는 0 기반
    if not Chart.HasPreservedXml then
      Log('chart 0 has no original ChartML attached');   // Log는 여러분의 루틴이다

    Chart.Title := Chart.Title;                  // 같은 값: 여전히 재생된다
    Workbook.Sheets[1].Charts[1].Title :=
      'Revenue by region, Q3 2026';              // 이 차트는 병합 경로를 간다
    Workbook.SaveAs('quarterly-review-out.xlsx');
  finally
    Workbook.Free;
  end;
end;

차트 하나를 편집하면 병합에서 무엇이 살아남는가?

짧은 답: 타입 모델이 소유하지 않은 모든 것. 제목, 시리즈, 데이터 캐시, 플롯 그룹, 축은 모델이 소유하므로 새로 생성된 트리가 결정한다. 모델링되지 않은 속성, 네임스페이스 선언, 스타일, 확장 목록, 서식 노드는 원본 트리에서 와서 이전에 붙어 있던 짝 노드 곁에 다시 끼워진다

시리즈를 지우면 원본 XML의 그 위치에 여전히 c:ser가 있다는 이유만으로 병합이 그것을 되살려서는 안 된다. 모델이 소유한 것은 모델의 답이 "없음"일 때조차 모델에서 가져온다. 속성 수준에서도 같다. 알려진 텍스트 스타일의 굵게, 기울임, 밑줄, 취소선, 글꼴 크기를 지우면 병합은 빠진 속성을 지우기로 읽고 b나 sz를 원본 defRPr에서 되복사하지 않는다. 같은 엘리먼트의 알 수 없는 속성은 제자리를 유지한다

제목도 같은 규칙을 따른다. 임포트할 때 HotXLS는 c:title의 네이티브 rich run을 run별 폰트, 크기, 언어 태그와 함께 간직하고, 텍스트가 손대지 않은 한 재생한다. 새 Title을 할당하면 그 rich 콘텐츠는 버려진다. 제목은 TitleTextStyle에서 서식을 얻은 하나의 run으로 다시 쓰인다. 오래된 run을 새 텍스트에 병합하는 것은 새 단어가 어느 run에 속하는지 추측하겠다는 뜻이고, 여기서의 추측은 아무도 타이핑한 적 없는 제목을 낳는다

스키마 순서는 Office가 깐깐하게 따지는 곳에서 유지된다. extLst는 스키마가 요구하는 대로 부모의 마지막 자식으로 남는다. 마크업 호환성 블록은 별도의 취급을 받는다. 리더는 mc:Choice를 건너뛰고 mc:Fallback을 읽으며, 그 fallback이 모델이 아는 플롯 그룹을 감싸고 있으면 병합은 새 타입 콘텐츠를 fallback 하위 트리에 다시 쓴다. 순진한 병합은 편집된 플롯 그룹을 내보내고 예전 사본을 fallback 가지 안에 남겨 두어, 저장된 차트가 같은 그룹을 두 번 갖게 만든다

ClearPreservedXml로 한 차트 빼기

TXLSXChart.ClearPreservedXml은 한 차트의 원본 XML과 지문을 버리고, 다음 저장이 그 차트를 순수하게 타입 모델에서 재구축하게 한다. 차트별 결정이다. PreserveUnsupportedParts는 여전히 임포트 때 보존 상태를 만들지를 결정하고 차트만이 아니라 다른 미지원 패키지 파트까지 아우르므로, 깨끗한 차트 하나를 얻겠다고 그것을 끄는 것은 잘못된 손잡이다

버리는 것이 작업의 목적인 차트, 예컨대 은퇴한 사내 테마의 서식을 아직 달고 있는 템플릿 차트를 지운다. 디버깅 반사로는 건드리지 마라. 편집 후 잘못 렌더링되는 차트는 대개 모델링 질문이고, 보존 XML을 지우면 Excel이 원래 쓴 것의 유일한 사본을 버리는 셈이다

먼저 검사가 필요한 하나의 사례가 있다. 오차 막대나 추세선이 타입 모델이 표현할 수 없는 선 서식을 쓰면 그 LineAppearanceSupported 속성이 False이고, 모델 밖 서식을 가진 추세선 레이블은 LabelAppearanceSupported로 같은 것을 보고한다. HotXLS는 어느 쪽이든 더 수수한 버전으로 조용히 갈아치우지 않는다. 차트가 의지할 보존 XML이 없어지는 순간, 저장은 표현되지 않은 선이나 레이블 모양의 이름을 대는 EArgumentException으로 멈춘다. 지우기 전에 둘 다 검사하는 데는 루프 하나면 족하다

// 여러분의 헬퍼, HotXLS 일부 아님: 모든 오차 막대 선과
// 추세선과 추세선 레이블이 타입 모델만으로 쓰일 수 있으면 True
function ChartRebuildable(Chart: TXLSXChart): Boolean;
var
  I, T: Integer;
  S: TXLSXChartSeries;
begin
  Result := False;
  for I := 0 to Chart.SeriesCount - 1 do    // 모든 플롯 그룹을 아우른다
  begin
    S := Chart.Series[I];
    if (S.ErrBarX <> nil) and not S.ErrBarX.LineAppearanceSupported then Exit;
    if (S.ErrBarY <> nil) and not S.ErrBarY.LineAppearanceSupported then Exit;
    for T := 0 to S.TrendlineCount - 1 do
    begin
      if not S.Trendlines[T].LineAppearanceSupported then Exit;
      if not S.Trendlines[T].LabelAppearanceSupported then Exit;
    end;
  end;
  Result := True;
end;

// ...
Chart := Workbook.Sheets[1].Charts.FindByName('Old theme chart');
if (Chart <> nil) and ChartRebuildable(Chart) then
  Chart.ClearPreservedXml      // 다음 저장이 이 차트를 모델에서 재구축한다
else
  Log('kept original ChartML for this chart');

AddPlotGroup와 보조 축으로 만드는 콤비네이션 차트

TXLSXChart.AddPlotGroup는 자기 차트 계열과 자기 주축 또는 보조 축 배정을 갖춘 순서 있는 플롯 그룹을 덧붙인다. 재무 팀이면 모두가 요구하는 차트, 곧 매출은 왼쪽 축의 열로, 마진율은 오른쪽의 선으로 두는 차트가 이것으로 커버된다. 각 차트는 ValueAxis, CategoryAxis, SecondaryValueAxis, SecondaryCategoryAxis, SeriesAxis를 노출하며 위치, 축 식별자, 교차, 날짜 단위, 레이블 간격, 표시 형식, 표시 여부에 대한 타입 설정을 갖는다

단일 타입 차트의 평면 API도 여전히 있고 그룹에 사상된다. ChartType과 AddSeries는 첫 플롯 그룹을 가리키고, SeriesCount와 Series[]는 모든 그룹의 시리즈를 순서대로 걷는다. 위의 헬퍼가 선 시리즈까지 보는 이유다. 순서가 한 곳에서 문제 된다. TXLSXCharts.Add는 플롯 그룹이 전혀 없는 차트를 만들고, AddSeries든 AddPlotGroup이든 먼저 도는 것이 그룹 하나를 만든다. 갓 만든 차트에 AddPlotGroup(xlsxChartLine, xlsxAxisSecondary)을 먼저 호출하면 선이 그룹 하나가 되고 차트 타입은 line으로 바뀌며, 다음에 추가하는 매출 시리즈가 그 안에 떨어진다. 두 그룹을 명시적으로 만들면 모호함이 사라진다. 예제가 그렇게 하는 이유다

var
  Chart: TXLSXChart;
  Columns, Line: TXLSXChartPlotGroup;
begin
  // 1..15행, 5..12열에 고정. 아직 플롯 그룹은 없다
  Chart := Sheet.Charts.Add(xlsxChartColumn, 'Revenue and margin',
    1, 5, 15, 12);
  Columns := Chart.AddPlotGroup(xlsxChartColumn);              // 그룹 1, 주 축
  Columns.AddSeries('Revenue', 'Data!$A$2:$A$13', 'Data!$B$2:$B$13');

  Line := Chart.AddPlotGroup(xlsxChartLine, xlsxAxisSecondary); // 그룹 2
  Line.AddSeries('Margin %', 'Data!$A$2:$A$13', 'Data!$C$2:$C$13');
  Line.ZOrder := 1;                     // 열 앞에 선을 그린다

  Chart.SecondaryValueAxis.NumberFormat := '0%';
  Chart.SecondaryValueAxis.NumberFormatSourceLinked := False;
  Chart.SecondaryValueAxis.MinValue := 0;
  Chart.SecondaryValueAxis.MaxValue := 0.5;

  // 차트 기준 비율로 플롯 영역: 한 번의 호출로 x, y, width, height
  Chart.PlotAreaLayout.SetPositionAndSize(0.08, 0.12, 0.80, 0.72);
end;
Delphi의 HotXLS 콤비네이션 차트 다이어그램: AddSeries의 매출 열은 주 값 축에, AddPlotGroup의 마진 선은 보조 값 축에 놓이며 ChartType과 AddSeries는 그룹 하나만 가리킨다
AddPlotGroup 호출마다 자기 차트 계열과 주축/보조축 배정을 갖춘 순서 있는 그룹이 덧붙고, ChartType과 AddSeries는 여전히 그룹 하나를 가리킨다

플롯 그룹의 ZOrder는 겹침 순서를 제어한다. 차트 writer는 c:barChart와 c:lineChart 블록을 내보내기 전에 그룹을 이 값으로 정렬하고 작은 값을 뒤에 둔다. 모든 순서 있는 그룹이 모델링되어 있으므로 콤비네이션 차트를 열고 다시 저장해도 각 차트 계열이 보조 축, 날짜 축, 시리즈 축과 함께 유지된다. 차트 수식 참조와 캐시된 시리즈 값은 워크북 복사와 열기/저장 주기를 견디고, 차트 앵커는 릴레이션십 식별자로 차트 파트와 짝지어지므로, 릴레이션십 항목을 다른 도구가 재정렬한 패키지조차 각 차트를 자기 워크시트 위치에 놓는다

Office는 수동 레이아웃을 왜 무시하는가?

좌표가 불완전하게 도착했기 때문이다. Office는 x, y, w, h를 완전한 그룹으로 요구한다. c:manualLayout 안에 넷 중 하나라도 있을 때이고, 그렇지 않으면 엘리먼트 전체를 무시한다. 스키마는 넷 모두 선택 사항으로 표시하므로 x와 y만 있는 파일도 유효하며 Excel에서는 여전히 아무것도 하지 않는다

TXLSXChartLayout.SetPositionAndSize가 써야 할 호출이다. 네 값을 모두 설정하고 네 모드 엘리먼트를 존재 표시하며 레이아웃을 활성화하는 것이 한 번에 이뤄진다. 개별 X, Y, Width, Height 속성은 각각 하나의 값을 설정하고, HotXLS는 존재하는 엘리먼트만 정확히 쓴다. 파일을 읽을 때 아홉 개의 선택 자식 각각의 존재 여부를 유지하는 것과 같은 방식이다. 덕분에 손대지 않은 레이아웃은 소스에 충실하다. 반면 속성 할당 두 번으로 만든 레이아웃은 좌표 두 개로 기록되고 Office는 그것을 건너뛴다. 같은 TXLSXChartLayout 클래스가 PlotAreaLayout, TitleLayout, LegendLayout, 각 축의 TitleLayout 뒤에 앉아 있다

Delphi의 HotXLS 수동 레이아웃 규칙 다이어그램: x, y, w, h는 완전한 그룹으로 도착해야 하며 그렇지 않으면 Office가 manualLayout 엘리먼트 전체를 무시한다. edge와 factor 모드가 있고 layoutTarget은 플롯 영역으로 한정된다
Office는 x, y, w, h가 완전한 그룹으로 도착했을 때만 수동 레이아웃을 적용하므로 SetPositionAndSize가 넷을 한 번의 호출로 쓴다

두 기본값은 규격이 허용하는 전부가 아니라 Excel의 동작을 따른다. 새 레이아웃은 edge를 XMode와 YMode에, factor를 WidthMode와 HeightMode에 쓴다. 표준은 다른 조합도 허용하지만 Excel은 위치를 edge로, 크기를 factor로 저장하며 다른 것은 저장할 때 변환한다. 그리고 layoutTarget(Target, inner 또는 outer)은 플롯 영역에 대해서만 기록되고, 제목, 축 제목, 범례 레이아웃은 생략한다. Microsoft의 차트 스키마 구현 노트가 그 엘리먼트 아래에서는 이를 허용하지 않기 때문이다

통합 문서 왕복에 끼워 넣기

차트 보존은 더 넓은 규칙의 한 사례다. 다른 곳에서 저작된 문서를 편집하는 라이브러리는 요청받은 것만 바꾸고 나머지는 만난 그대로 두어야 한다. HotXLS는 같은 논리를 테마, 계산 체인, 워크시트 확장에 적용하며 이는 테마, extLst, calcChain의 무손실 왕복에서, VBA 프로젝트와 외부 링크에는 VBA와 외부 링크 보존에서 다룬다. 백지부터 차트 만들기, 이미지 배치, 도형 그리기는 HotXLS의 차트, 이미지, 도형을 보라

HotXLS는 머신에 Excel이 설치되지 않은 상태에서 네이티브 Delphi·C++Builder 코드로 XLSX 차트를 읽고 편집하고 쓰므로, 차트 fidelity는 자동화 서버가 아니라 라이브러리가 결정한다. 차트 API 레퍼런스와 평가판 빌드는 HotXLS Delphi 스프레드시트 컴포넌트 페이지에 있다