HotXLS는 Excel 차트 날짜 축을 고정 일수가 아니라 달력 산술로 렌더링합니다. lxChart 유닛의 TXLSChartDateAxisTransform 클래스는 모든 좌표를 통합 문서 serial로 유지하고, 각 월·년 눈금을 축 최솟값에서 IncMonth와 IncYear로 직접 계산하며, 월말을 실제 달력에 맞춰 클램프합니다. 그래서 1월 31일에 고정된 계열은 미끄러지는 대신 2월 28일, 3월 31일, 4월 30일에 눈금을 얻습니다. 같은 트랜스폼이 일·월·년 단위를 자동으로 고르며, HTML, SVG, 페이지 분할 PDF 차트 렌더러가 공유합니다
이것이 막아 주는 버그는 월말 마감을 차트로 그려 본 사람이라면 낯익은 것입니다. 한 달을 30일로 취급하는 렌더러는 첫해가 끝나기 전에 5일이나 앞서 미끄러집니다. 더 똑똑하게 달력의 달을 쓰면서도 각 눈금을 이전 눈금에서 전진시키는 렌더러는 더 조용히 틀어집니다. 1월 31일이 2월 28일이 되고, 다음 스텝은 3월 28일에 떨어지고, 그 뒤의 모든 눈금은 28일에 박혀 버립니다. 차트는 그럴듯해 보이고 라벨은 틀렸으며, 재무 담당자가 3월 잔고가 왜 분기 말보다 3일 앞에 찍혔냐고 물을 때까지 아무도 눈치채지 못합니다
월 눈금은 누적하면 왜 어긋날까요?
월 눈금이 어긋나는 이유는 클램핑이 정보를 버리기 때문입니다. 1월 31일이 한번 2월 28일로 클램프되면 계열이 31일을 원했다는 사실은 사라지고, 클램프된 값에서 잡은 스텝은 그 손실을 물려받습니다. TXLSChartDateAxisTransform.BuildTicks는 이전 눈금에서 전진하는 일이 없습니다. 눈금 i는 언제나 AddUnits(MinValue, UnitKind, Step * i, ...)로 계산되는데, 이는 축 최솟값에서 잰 min + 단위 × 인덱스입니다. AddUnits는 일 단위에서는 개수를 serial에 바로 더하고, 월·년 단위에서는 serial을 실제 날짜로 바꿔 IncMonth나 IncYear를 호출한 뒤 되돌립니다. 루프는 모든 면에서 경계가 있습니다. 결과 배열은 호출자가 뭘 요청하든 4096개로 갑이고, Step * i는 곱하기 전에 MaxInt를 검사하며, 눈금이 더 커지지 않거나 축 최댓값을 넘는 즉시 생성이 멈춥니다
uses
SysUtils, lxChart;
var
Ticks: TXLSChartDateValues;
MinSerial, MaxSerial: Double;
I, Count: Integer;
begin
// 1900 날짜 체계(Dates1904 = False), 월말 앵커 계열
MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
EncodeDate(2026, 1, 31), False);
MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
EncodeDate(2026, 12, 31), False);
// 한 달 스텝, 눈금 12개 목표, 최대 64를 넘지 않음
Count := TXLSChartDateAxisTransform.BuildTicks(MinSerial, MaxSerial,
1, xcduMonths, False, 12, 64, Ticks);
for I := 0 to Count - 1 do
Writeln(TXLSChartDateAxisTransform.FormatValue(Ticks[I], False));
// 2026-01-31, 2026-02-28, 2026-03-31, 2026-04-30 ... 2026-12-31
end;
BuildTicks에는 정직한 근사가 하나 있습니다. 요청된 스텝이 0일 때 메서드는 한 달을 30일, 1년을 365로 취급해 스텝 크기를 추정한 다음, 날 스텝을 10의 거듭제곱에 대한 1, 2, 5 배수로 반올림합니다. 이 추정은 눈금 사이에 단위가 몇 개 들어갈지 정할 뿐입니다. 눈금 위치 자체는 여전히 IncMonth와 IncYear에서 나오므로, 근사가 눈금 밀도를 바꿀 수는 있어도 눈금을 달력에서 이동시키지는 못합니다
왜 TDateTime으로 변환하지 않고 통합 문서 serial을 유지할까요?
HotXLS의 날짜 축은 좌표 공간으로 통합 문서 serial을 유지합니다. Delphi의 TDateTime에는 serial 60, 즉 1900 날짜 체계가 호환성 흑역사로 물려받은 유령 날짜 1900년 2월 29일의 자리가 없기 때문입니다. TrySerialToDateTime은 60 미만의 serial을 하루씩 밀고 serial 60을 2월 28일로 접는데, 점 하나에 라벨을 붙이는 데는 괜찮지만 기하에는 치명적입니다. 모든 점을 먼저 TDateTime으로 바꾸면 유령 날짜 양옆 이틀의 간격이 Excel이 그리는 것보다 한 단위 좁아지고, 그래서 1900년 3월 이전에 그려진 모든 것이 이후와 상대적으로 밀립니다. TXLSChartDateAxisTransform.FormatValue는 같은 자리를 특별 취급해 Excel과 똑같이 1900-02-29로 라벨을 붙입니다. 1904 통합 문서에서 serial 0은 1904년 1월 1일이고, 트랜스폼은 고정 1462일 오프셋을 양방향으로 적용합니다. 두 에포크의 전체 이야기는 Excel 날짜 serial과 1904 체계, 숫자 서식 글에 있습니다
HotXLS는 일·월·년 단위를 어떻게 자동으로 고를까요?
TXLSChartDateAxisTransform.DetectUnit은 데이터의 간격이 실제로 요구하는 가장 잘게 쪼개진 달력 단위를 고릅니다. 메서드는 먼저 NaN과 무한 값을 버리고, 나머지를 O(n log n)으로 한 번 정렬한 뒤 중복 날짜를 건너뜁니다. 인접 간격이 하나라도 달력상 한 달보다 짧으면 단위는 일이고, 그렇지 않으면서 간격이 하나라도 달력상 1년보다 짧으면 단위는 월이고, 아니면 년입니다. 판정은 30일 임계값이 아니라 IncMonth(Previous, 1) > Current이므로, 1월 31일, 2월 28일, 3월 31일로 이어지는 월말 계열은 올바르게 월 단위 데이터로 감지됩니다. 서로 다른 유효 날짜가 둘 미만이면 잴 간격이 없고 메서드는 일로 폴백합니다
이어서 ResolveUnits가 감지된 단위를 차트가 선언한 것과 합칩니다. 명시된 base 단위는 존중되고, 빠진 major나 minor 단위는 base보다 더 성기면 감지된 단위를 물려받습니다. 사람들이 놓치는 마지막 단계가 이것입니다. 유효한 major나 minor 단위가 base 단위보다 잘게 쪼개져 있으면 base를 거기에 맞춰 낮춥니다. base 단위는 눈금이 존재하기 전에 데이터 점이 정규화되는 기준으로, Normalize는 값을 자기 날, 자기 달의 1일 또는 1월 1일로 스냅합니다. 일 단위 major 아래의 월 단위 base라면 눈금 코드가 보기도 전에 한 달치 점이 슬롯 하나로 뭉개질 것입니다. 눈금 밀도에도 경계가 있습니다. 플롯 길이, 축 폰트의 측정 폭, 라벨 숫자 서식, 회전된 라벨의 사영이 그 기준이며, 모든 백엔드는 하나의 공유되고 경계가 있는 눈금 배열에서 라벨과 눈금 표식, 격자선을 그립니다. major 눈금과 겹치는 minor 눈금은 한 번만 그려집니다
dateAx 요소는 무엇을 저장하고, 생략은 무엇을 뜻할까요?
ChartML에서 날짜 축의 모든 달력 설정은 선택 사항이고, 생략하는 것은 기본값을 쓰는 것과 다릅니다. ECMA-376 Part 1 §21.2(DrawingML Charts)의 CT_DateAx 타입은 baseTimeUnit, majorTimeUnit, minorTimeUnit, auto가 각각 나오거나 안 나오거나를 허용하는데, 생략된 baseTimeUnit은 Excel이 스스로 정하라는 뜻으로 명시된 days와 다릅니다. 그래서 TXLSXChartAxis는 각 값을 존재 플래그 옆에 저장합니다. BaseTimeUnit에는 BaseTimeUnitSet, MajorTimeUnit에는 MajorTimeUnitSet, MinorTimeUnit에는 MinorTimeUnitSet, AutoDateAxis에는 AutoDateAxisSet이 붙습니다. 값을 대입하면 플래그가 켜지고, 플래그를 지우면 요소가 생략 상태로 돌아가며, XLSX 라이터는 플래그가 켜진 요소만 내보냅니다. auto는 자동 눈금 단위가 아니라 category 대 날짜 자동 판별을 뜻한다는 점에 주의하세요
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
Chart: TXLSXChart;
Axis: TXLSXChartAxis;
M: Integer;
begin
Book := TXLSXWorkbook.Create;
try
Sheet := Book.Sheets.Add('Close');
Sheet.Cells[1, 1].Value := 'Month end';
Sheet.Cells[1, 2].Value := 'Balance';
for M := 1 to 12 do
begin
Sheet.Cells[M + 1, 1].Value :=
EncodeDate(2026, M, DaysInAMonth(2026, M)); // DateUtils
Sheet.Cells[M + 1, 2].Value := 1000 + M * 75;
end;
Chart := Sheet.AddLineChart('Month-end balance',
'Close!$A$2:$A$13', 'Close!$B$2:$B$13', 15, 1, 32, 9);
Axis := Chart.CategoryAxis;
Axis.Kind := xlsxAxisDate; // c:dateAx를 씀
Axis.BaseTimeUnit := xlsxChartTimeDays; // BaseTimeUnitSet도 함께 설정됨
Axis.MajorTimeUnit := xlsxChartTimeMonths;
Axis.MajorUnit := 1; // > 0이면 majorUnit이 설정된 것으로 표시
Axis.NumberFormat := 'mmm yyyy';
Axis.NumberFormatSourceLinked := False;
Axis.TextStyle.Rotation := -45; // 도 단위; 1/60000로 저장
// AutoDateAxis는 손대지 않음: c:auto 요소를 쓰지 않음
Book.SaveAs('month-end-balance.xlsx');
finally
Book.Free;
end;
end;
라벨과 방향도 같은 존재 인식 규칙을 따릅니다. 축 numFmt는 제목 뒤 눈금 표식 앞에 놓이고, sourceLinked 속성의 기본값은 true이며, TXLSXChartAxis는 서식 코드와 source-linked 값, NumberFormatSet을 따로 보관해서 빈 코드가 없는 요소로 오인되지 않습니다. 텍스트 회전은 1/60000도 단위로 저장되고 양수는 시계 방향입니다. TextStyle.Rotation은 평범한 도를 받고, SVG 출력은 각도를 그대로 쓰며, 양수가 반시계 방향인 페이지 분할 백엔드는 하나의 공유 경계에서 부호를 뒤집습니다. 뒤집힌 maxMin 축은 major와 minor 눈금, 격자선, 데이터 점, 추세선, 오차 막대를 함께 미러링하지 라벨만 미러링하는 일은 없습니다. 다른 곳에서 작성된 통합 문서를 열면 플래그가 작성자가 실제로 지정한 것을 알려 줍니다
var
Book: TXLSXWorkbook;
Axis: TXLSXChartAxis;
begin
Book := TXLSXWorkbook.Create;
try
if Book.Open('forecast.xlsx') <> 1 then
raise Exception.Create('Cannot open workbook');
Axis := Book.Sheets[0].Charts[0].CategoryAxis;
if Axis.Kind = xlsxAxisDate then
begin
if Axis.BaseTimeUnitSet then
Writeln('baseTimeUnit = ', Ord(Axis.BaseTimeUnit))
else
Writeln('baseTimeUnit omitted: Excel chooses at render time');
if Axis.AutoDateAxisSet then
Writeln('auto = ', Axis.AutoDateAxis);
end;
finally
Book.Free; // 다시 저장해도 생략된 요소는 생략된 채로 남음
end;
end;
달력 모델은 어디서 멈출까요?
달력 모델은 달력 단위를 표현할 수 없는 형식에서 멈추고, HotXLS는 그렇지 않은 척하지 않습니다. ODF 1.2에는 chart:interval-major가 있는데, 이는 일·월·년 개념이 없는 평범한 숫자라서 ODS로 저장된 Excel 월 단위 축은 자기 단위를 실을 수 없고, HotXLS는 그럴싸하게 흉내 낼 비표준 속성을 만들지 않습니다. 레거시 BIFF8 쪽에서 AxcExt 레코드는 고정 16비트 워드 아홉 개를 담고, 자동 플래그는 필드를 제거하는 게 아니라 마스킹할 뿐입니다. HotXLS는 마스킹된 최솟값, 최댓값, 간격, 단위, 교차 값을 읽고 되쓸 때 보존하고, 모르는 단위 코드는 Classic 모델에 그대로 유지하며, 유효한 날짜 단위 0, 1, 2(일, 월, 년)만 XLSX로 매핑합니다
날짜 축은 자기가 만들지 않은 파일도 견뎌야 하는 차트 모델의 한 층입니다. Excel 통합 문서가 날짜 축 꺾은선과 보조 축의 세로 막대 계열을 결합할 때, 보존된 ChartML과 조합 차트 글은 아무것도 바뀌지 않았을 때 HotXLS가 원본 차트 XML을 바이트 단위로 재생하고 뭔가 바뀌었을 때 타입 편집을 거기에 병합하는 방식을 보여 주고, 차트, 이미지, 드로잉 개요 글은 위에서 쓴 앵커링과 계열 API를 다룹니다. 이 모든 것은 Excel 자동화 없이 XLS와 XLSX 차트를 읽고 쓰고 렌더링하는 Delphi와 C++Builder용 HotXLS Delphi Component에 실려 있습니다