차트 두 개 들어 있는 워크북을 아무것도 편집하지 않는 열기-저장으로 돌리면, 밖으로 나오는 결과가 세 가지로 갈릴 수 있습니다. 하나는 원본에 차트 파트가 둘이었는데 셋으로 늘어난 출력입니다. 또 하나는 둘로 남아 있지만 바이트 비교를 해 보면 모든 xl/charts/chartN.xml이 다시 작성되고 계열 색이 흔들린 출력입니다. 세 번째는 차트 XML은 그대로인데 모든 차트가 몇 픽셀씩 셀 그리드선 위로 튀어 나간 출력입니다. Excel은 셋 모두 복구 프롬프트 없이 열어 줍니다. 각 증상은 로드·저장 경로의 결정 하나에서 나옵니다. 워크시트가 드로잉을 어떻게 찾는지, 차트의 변경 핑거프린트를 언제 찍는지, 드로잉 파트 안의 앵커 오프셋을 다시 쓰는지입니다
워크시트는 관계를 통해 자기 드로잉을 찾습니다
파트가 셋으로 늘어나는 증상은 파트 이름을 추측하는 로더에서 나옵니다. XLSX 패키지에서 워크시트는 <drawing r:id="…"/> 요소(ECMA-376 Part 1 §18.3.1.36)로 자기 드로잉을 가리키고, 이 r:id는 시트의 .rels 파트를 거쳐 ../drawings/drawing1.xml 같은 대상으로 해석됩니다. Excel은 우연히도 드로잉 번호를 예측 가능하게 매기기 때문에, 손으로 작성한 리더는 흔히 폴백을 하나 키웁니다. 관계가 없으면 시트 위치 n에 대해 xl/drawings/drawing{n}.xml을 시도하고, 아카이브에 있으면 그것을 쓰는 것입니다
// 손으로 작성한 XLSX 리더에 등장하는 지름길 (이대로 배포하지 마세요)
function GuessDrawingPart(Zip: TZipFile; SheetPos: Integer;
const RelTarget: string): string;
begin
Result := RelTarget; // 시트의 .rels에서 해석된 값, 없으면 ''
if Result = '' then
begin
Result := 'xl/drawings/drawing' + IntToStr(SheetPos + 1) + '.xml';
if Zip.IndexOf(Result) < 0 then
Result := '';
end;
end;
이 추측은 차트 없는 시트가 먼저 오기 전까지는 들어맞습니다. 첫 시트에 드로잉도 .rels 파트도 없는데 두 번째 시트가 Target="../drawings/drawing1.xml"으로 xl/drawings/drawing1.xml에 도달하는 워크북에서, 이 폴백은 시트 1에 한 번도 참조한 적 없는 드로잉을 집어 줍니다. chart1.xml은 시트마다 한 번, 두 번 파싱되고 저장은 차트 파트 셋을 씁니다. OPC 패키지에서 파트 이름은 관계 그래프가 부여하는 것 이상의 의미를 갖지 않는데, HotXLS의 OPC 관계 해석 워크스루가 바로 이 논지를 다룹니다
HotXLS Delphi Component는 엄격한 해석을 택합니다. 워크시트의 드로잉은 그 시트의 드로잉 관계 대상으로만 로드되고, 관계가 없는 시트는 xl/drawings/에 어떤 파일이 있든 드로잉을 받지 않습니다. 아무도 참조하지 않는 드로잉 파트는 시트에 붙지 않습니다
손대지 않은 차트를 복사할지 재빌드할지는 무엇이 결정할까요?
차트별 핑거프린트가 결정합니다. Open 시점에 PreserveUnsupportedParts가 True면(기본값이 True입니다) HotXLS는 파싱된 차트 모델 옆에 모든 차트 파트의 원본 바이트를 보관합니다. 그다음 모델을 자체 차트 작성기로 직렬화하고 그 출력에 두 숫자를 기록합니다. 길이와, UTF-16 코드 유닛에 대한 64비트 FNV-1a 해시입니다. 오프셋 기준값 14695981039346656037과 프라임 1099511628211이라는 표준 값을 씁니다. TXLSXChart.HasPreservedXml은 차트가 그 원본 바이트를 들고 있는지 알려 줍니다
저장 시점에 차트 모델을 다시 직렬화하고 두 숫자를 비교합니다. 길이와 해시가 같다는 것은 모델이 임포트 시점과 정확히 같다는 뜻이므로, 애플리케이션이 바꿀 수 있었던 것 중 아무것도 바뀌지 않았습니다. 이 경우 StrictOOXML이 꺼져 있으면 작성기는 소스 아카이브에서 압축된 엔트리를 차트의 새 파트 이름으로 곧장 복사해 넣고, 바이트는 디코딩되거나 다시 deflate되지 않습니다. 두 숫자 중 하나라도 다르면 원본 XML을 모델과 병합합니다. 오류 막대나 추세선의 선 서식을 바꾸는 것도 편집으로 칩니다. 그 서식은 직렬화된 모델의 일부가 아니더라도요
병합은 누군가 편집한 차트에 맞는 연산입니다. 모델이 커버하지 않는 원본 XML 부분을 살리고, 계열·축·플롯 그룹은 모델이 이기게 하고, 결과를 다시 직렬화합니다. 할 수 없는 것은 원본 바이트의 재현입니다. ChartML을 잃지 않고 Excel 차트 편집하기 노트가 병합이 살리는 것들을 다룹니다. 이 글의 주제는 편집되지 않은 차트가 병합에 아예 도달하지 않게 만드는 조건입니다
uses System.SysUtils, lxHandleX;
procedure ResaveUntouched(const Src, Dst: string);
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
S, C: Integer;
begin
Book := TXLSXWorkbook.Create;
try
Book.PreserveUnsupportedParts := True; // 기본값. Open 전에 True여야 합니다
Book.StrictOOXML := False; // Strict 출력은 모든 차트를 재생성합니다
if Book.Open(Src) <> 1 then
raise Exception.Create('Cannot open ' + Src);
for S := 1 to Book.Sheets.Count do // Sheets[]는 1부터
begin
Sheet := Book.Sheets[S];
for C := 0 to Sheet.Charts.Count - 1 do // Charts[]는 0부터
Writeln(Sheet.Name, ' / ', Sheet.Charts[C].Name, ': original kept = ',
BoolToStr(Sheet.Charts[C].HasPreservedXml, True));
end;
if Book.SaveAs(Dst) <> 1 then
raise Exception.Create('Cannot save ' + Dst);
finally
Book.Free;
end;
end;
출처가 옮긴 서식을 벗겨 내는 등 의도적으로 모델에서 재생성하고 싶을 때는 TXLSXChart.ClearPreservedXml이 저장된 바이트를 버리고, 그 차트는 모델만으로 작성됩니다
그렇다면 모든 차트는 왜 병합 경로로 가게 될까요?
한 발 이르게 찍은 핑거프린트는 영원히 일치하지 않기 때문입니다. 차트 XML은 한 번의 패스로 읽히는 일이 드뭅니다. 스트리밍 패스가 계열·축·플롯 그룹을 만들고, 그다음 후속 패스들이 계열이 존재해야 뽑기 쉬운 세부를 원본 차트 텍스트에서 읽습니다. 각 <c:ser>의 <c:smooth> 플래그, 마커 채움과 마커 선의 srgbClr 값, 축 교차 모드, 주·보조 눈금 표시 스타일이 그 대상입니다. 스트리밍 패스가 도는 동안에는 계열 컬렉션이 아직 존재하지 않으므로, 이 패스들은 그 뒤에 올 수밖에 없습니다
핑거프린트를 스트리밍 패스 끝, 후속 패스들이 돌기 전에 찍었다고 해 봅시다. 그러면 그것은 smooth 플래그도 마커 색도 없고 눈금 표시가 기본값인 모델을 묘사합니다. 저장 시점에는 직렬화기가 완성된 모델을 대상으로 돌며 <c:smooth val="1"/>과 복구된 색을 내놓습니다. XML은 더 길어지고 해시는 달라지며, 보존된 워크북의 모든 차트가 저장마다 병합을 거칩니다. smoothed 계열과 커스텀 마커를 쓰는 선 차트에서는 재생성된 <c:ser> 노드가 원본을 대체하고, 계열 색이 흔들리는 것은 바로 그때입니다
HotXLS는 모든 복구 패스가 끝난 뒤에만 핑거프린트를 찍으므로, 해시는 애플리케이션이 처음 보는 모델을 묘사합니다. 같은 두 숫자는 성공적인 저장 뒤에 출력 파일 기준으로 다시 세워지는데, 이 두 번째 캡처 역시 완전히 파싱된 모델에서 출발합니다. 변경 감지 해시는 모델을 여전히 변이시킬 수 있는 마지막 패스 뒤에 찍혀야 합니다. 그보다 일찍 찍으면 저장마다 실패하는 검사가 됩니다
앵커 오프셋은 드로잉 파트에 삽니다
그리드 스냅 증상은 차트 XML과 무관합니다. 차트가 시트의 어디에 앉는지는 드로잉 파트에 저장되고, 흔한 형태는 xdr:twoCellAnchor(ECMA-376 Part 1 §20.5)입니다. from 모서리와 to 모서리가 각각 col, colOff, row, rowOff를 담습니다. 오프셋 단위는 EMU로 인치당 914400, 포인트당 12700이고, Excel은 마우스로 차트를 끌거나 크기 조절한 적이 있으면 0이 아닌 값을 씁니다. from 모서리가 열 0, 행 0에 rowOff 19049이고 to 모서리가 열 8, 행 15에 colOff 247650, rowOff 66674인 파일이라면, 차트는 1행 꼭대기에서 1.5포인트 아래에서 시작해 I열에 1/4인치가 조금 넘게 들어간 지점에서 끝납니다
그 네 숫자를 읽어 놓고 모든 모서리에 <xdr:colOff>0</xdr:colOff>을 써 넣는 작성기는 모든 차트를 그리드선 위로 옮겨 놓습니다. 차트 파트는 바이트 단위로 재생되었을 텐데도 차트는 움직입니다. 둘이 다른 파트에 살기 때문입니다. HotXLS는 로드한 앵커 종류(twoCellAnchor, oneCellAnchor, absoluteAnchor가 PhysicalAnchorKind로 노출됨)를 EMU 값과 함께 보관하고 그대로 다시 씁니다. 오프셋은 FromOffsetXEMU, FromOffsetYEMU, ToOffsetXEMU, ToOffsetYEMU로 읽을 수 있고, 그 뒤의 단위 변환은 HotXLS 이미지 지오메트리와 EMU 스케일링 노트에 정리되어 있습니다
procedure NudgeChart(Chart: TXLSXChart);
const
EmuPerPoint = 12700;
begin
if Chart.PhysicalAnchorKind <> xlsxAnchorTwoCell then
Exit;
Writeln(Format('from R%dC%d +(%d, %d) EMU to R%dC%d +(%d, %d) EMU',
[Chart.FromRow, Chart.FromCol, Chart.FromOffsetXEMU, Chart.FromOffsetYEMU,
Chart.ToRow, Chart.ToCol, Chart.ToOffsetXEMU, Chart.ToOffsetYEMU]));
// 프레임 전체를 한 번에 교체합니다. 행과 열은 1부터,
// 오프셋은 셀 안 EMU이고 마지막 인자는 editAs 동작입니다
Chart.SetTwoCellAnchor(Chart.FromRow, Chart.FromCol, Chart.ToRow, Chart.ToCol,
Chart.FromOffsetXEMU + 6 * EmuPerPoint, Chart.FromOffsetYEMU,
Chart.ToOffsetXEMU + 6 * EmuPerPoint, Chart.ToOffsetYEMU, Chart.AnchorType);
end;
이동이나 크기 조절이 의도라면 SetTwoCellAnchor를 쓰세요. 투셀 차트에서 FromRow, FromCol, ToRow, ToCol을 대입하면 그 셀 인덱스만 바뀌고 오프셋은 제자리에 남습니다. 조금의 조정에는 맞는 동작이지만 차트를 처음부터 배치할 때는 틀린 동작입니다. SetTwoCellAnchor는 프레임을 통째로 검증하고 끝 모서리가 시작보다 앞서거나 오프셋이 Excel EMU 범위를 벗어나면 ERangeError를 raise 합니다. AddChart로 만든 차트는 오프셋 0으로 시작하므로, 값을 주기 전까지는 셀 경계에 딱 붙어 있습니다
바이트 비교로 왕복 증명하기
출력을 Excel에서 여는 것은 여기서 별것을 증명하지 못합니다. Excel은 로드 시점에 워낙 많은 것을 정규화해서, 누군가 마커 색이 바뀐 것을 눈치채기 전까지는 다시 직렬화된 차트도 똑같아 보입니다. 몇 포인트짜리 그리드 스냅은 화면에서 더 놓치기 쉽습니다. 자동화할 가치가 있는 검사는 편집 없는 열기-저장 뒤에 차트 파트를 바이트 단위로 비교하는 것이고, RTL의 TZipFile이면 충분합니다
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 출력은 설계상 차트를 재생성합니다. 편집되지 않은 차트가 저장을 살아남는다고 약속하는 파이프라인이라면 어디서든 꺼 두세요- Strict OOXML 네임스페이스로 작성된 소스 패키지는 transitional 저장에서 transitional 네임스페이스로 변환되므로, 다른 것이 아무것도 안 바뀌어도 차트 바이트는 달라집니다
Open과SaveAs사이의 코드는 차트 속성을 자유롭게 읽어도 되지만, 모델을 바꾸는 세터는 하나라도 핑거프린트를 뒤집어 그 차트를 병합으로 보냅니다. 올바른 동작이고 이 검사가 재는 바깥입니다- 차트 파트는 저장 시점에 워크북 단위 카운터로 번호가 다시 매겨지므로, 시트나 차트 순서가 바뀐 워크북은 동일한 바이트를 다른
chartN.xml이름 아래 놓습니다. 이 경우의 검사기는 이름 대신 드로잉 관계를 따라갑니다
앵커는 값으로 비교해야 합니다. 드로잉 파트는 자기 차트들을 둘러싸고 새로 작성되므로 그 바이트를 diff하는 것은 잘못된 방법입니다. 두 파일을 다시 열어 차트마다 FromOffsetXEMU부터 ToOffsetYEMU까지 비교하면 247650을 0으로 바꾸는 작성기를 잡아냅니다. 워크시트·드로잉·차트 관계를 훑으며 중복, 고아, 잘못 걸린 차트 참조에서 실패하는 구조 검사는 바이트 비교가 시작되기도 전에 파트 셋 사례를 잡습니다
이 모든 것은 머신에 Excel 설치 없이 네이티브 Delphi와 C++Builder 코드에서 돌아가므로, 배치 잡에서 돌아온 워크북만이 차트에 무슨 일이 있었는지에 대한 유일한 증거입니다. 라이선스, 전체 차트 API, 평가판 빌드는 HotXLS Delphi 스프레드시트 컴포넌트 페이지에 있습니다