HotXLS는 일반 숫자 셀 값과 수식 캐시를 XLSX에 유효숫자 17개로 기록하고, 읽기에서는 한도 있는 정확한 십진-이진64 검사로 Delphi RTL 파싱을 바로잡아, 셀에 넣은 Double이 저장과 다시 열기를 거쳐 비트 단위로 돌아옵니다. Excel이 화면에 보여 주는 15자리로는 IEEE 754 binary64 값을 식별하기에 부족하며, 그 간극은 금융과 ID 컬럼이 사는 바로 그곳, 1015를 넘는 정수에서 드러납니다
변화를 강제한 버그는 무해해 보였습니다. 워크시트 수식 INT(-1E15-0.1)은 -1000000000000001로 평가되는데, 완벽히 표현 가능한 double입니다. HotXLS는 이를 정확히 계산하고 캐시하고 파일을 저장했지만 디스크의 캐시는 -1E15로 읽혔습니다. Excel은 재계산 후에야 원래 값을 보여 줄 것이고, 캐시된 값을 신뢰하는 소비자, 즉 대부분의 소비자는 조용히 하나 어긋난 숫자를 받았습니다. 아무것도 던지지 않았고 경고도 없었으며 파일은 검증을 통과했습니다
double에는 유효숫자 15개가 왜 부족할까
십진 15자리는 binary64로 갔다가 돌아오는 여행은 보장하지만, 반대 방향에는 17이 필요합니다. IEEE 754 binary64는 53비트 가수를 갖고, 이것은 값이 어디에 앉았는지에 따라 십진 15자리에서 17자리 사이가 됩니다. 최대 15개 유효숫자의 십진 문자열은 double로 변환됐다가 변하지 않고 출력됩니다. 그러나 double은 자기 십진 표기가 자신을 유일하게 식별하기까지 최대 17자리가 필요하고, 253 위의 범위에서 인접 double들은 2씩 떨어져 있으며, 1015와 253 사이에서는 0.125에서 1씩 떨어져 있습니다
Delphi의 FloatToStr는 유효숫자 15개로 포매팅하는데, 이것은 Excel이 표시에 쓰는 정밀도와 같습니다. 그 일치가 바로 결함이 이렇게 잘 숨는 이유입니다. Excel에서 파일을 열면 셀이 Delphi 프로그램에서와 정확히 똑같아 보입니다. Excel도 15자리만 보여 주니까요. 차이는 저장된 double을 비교하거나, 가까운 두 값을 빼거나, 캐시를 산술하는 무언가에 먹일 때만 보입니다. 값 -1000000000000001은 유효숫자 16개를 가지므로 15로 반올림하면 -1000000000000000이 되고 끝의 단위는 영영 사라집니다
ECMA-376 Part 1 §18.3.1.96은 셀 값을 텍스트로 실는 <v> 요소를 정의하며, 스키마의 어디에도 숫자 셀에 라이터가 얼마나 많은 자리를 내보내야 하는지 정해 놓지 않았습니다. 정밀도는 전적으로 라이터의 책임이고, 그래서 두 생산자가 모두 유효한 XLSX를 쓰면서도 셀 안의 숫자가 무엇인지에 대해서는 어긋날 수 있습니다
Delphi에서 유효숫자 17개 쓰기
HotXLS는 이제 숫자 셀에 하나의 불변식을 씁니다. invariant 형식 설정의 정밀도 17 ffGeneral FloatToStrF입니다. 셀 라이터의 날짜 분기는 이미 정확히 그 프로젝션을 쓰고 있었으므로, 평범한 숫자는 단지 거기 합류했습니다. 바깥에서 보면 API는 아무것도 바뀌지 않습니다. Double이나 수식을 대입하고 저장하면 <v>에 착지하는 토큰이 double을 재현하기에 충분히 정밀합니다
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Book := TXLSXWorkbook.Create;
try
Sheet := Book.Sheets.Add('Ledger');
Sheet.Cells[1, 1].Value := 9007199254740991.0; // 2^53 - 1
Sheet.Cells[2, 1].Value := -1000000000000001.0;
Sheet.Cells[3, 1].Formula := 'INT(-1E15-0.1)';
if Book.Recalculate <> lxOk then
raise Exception.Create('Recalculation failed');
// 값과 수식 캐시는 유효숫자 17개로 기록됩니다
if Book.SaveAs('ledger.xlsx') <> lxOk then
raise Exception.Create('Save failed');
finally
Book.Free;
end;
end;
17자리는 패딩 규칙이 아니라 천장입니다. ffGeneral은 끝의 0을 버리므로 0.5는 여전히 0.5로 쓰이고 작은 정수는 짧게 남습니다. 추가 자리가 진짜로 필요한 값만 그것을 얻습니다. 비용은 이전에 잘리던 셀들에서 몇 바이트인데, 여러분이 출시할 최고로 싼 정확성 수정입니다
17자리를 써도 읽기에서 값이 잃히는 이유
충분한 자리를 쓰는 것은 리더가 그것을 가장 가까운 double로 변환할 때만 도움이 되는데, RTL의 Val은 항상 그렇게 하지 않습니다. 검증 중 테스트 기계의 RTL은 9007199254740990.7과 평범한 정수 9007199254740991을 모두 인접한, 틀린 binary64 값으로 읽었습니다. 두 문자열은 모두 253-1에 착지해야 합니다. 하나는 HotXLS가 만들 수 있는 17자리 표기이고 다른 하나는 Excel이 같은 값을 쓰는 방식입니다. 단독으로 고친 라이터는 HotXLS 자신이 올바로 읽어 올 수 없는 파일을 출시했을 것입니다
그래서 HotXLS는 Val을 문지기로 유지하고 그 답을 바로잡습니다. 기존 숫자 문법이 날짜가 아닌 토큰을 받아들인 뒤, 정확 파서가 십진수를 big-integer 분수로 만들어 RTL 후보의 양쪽 이진 중점과 비교합니다. 십진수가 아래 중점 아래면 후보는 ulp 하나 내려가고, 위 중점 위면 올라가며, 정확한 무승부에서는 짝수 가수가 이깁니다. IEEE 754의 round-to-nearest-even 규칙입니다. 후보는 최대 여덟 번의 이웃 조정이 허용되는데, Val은 한 마일이 아니라 한 단위만큼 틀리므로 충분합니다
수정은 의도적으로 한도가 있습니다. 48자를 넘는 토큰, 유효숫자 17개를 넘는 토큰, 지수 크기가 324를 넘는 토큰은 이를 건너뛰고 레거시 파서 결과를 유지하며, 내부 실패도 같은 것으로 폴백합니다. big integer는 32비트짜리 48개 림을 쓰는데, 그 한계 안의 가장 넓은 분자와 분모에 충분합니다. 여기서 하나의 이식성 디테일이 중요합니다. 림은 명시적으로 UInt32로 선언됩니다. LongWord가 당연한 선택처럼 보이지만 Delphi의 Linux64 타깃에서는 8바이트이고, 조용히 폭이 두 배가 되는 림 타입은 산술의 모든 캐리와 시프트를 깨뜨립니다
왕복을 비트 단위로 검증하기
이 테스트에서 =로 double을 비교하는 것은 괜찮습니다. 목표가 정확한 동일성이기 때문입니다. 그러나 비트 패턴 비교가 실패 보고를 더 유용하게 만듭니다. ulp 하나의 표류는 동일하게 출력되는 두 숫자가 아니라 raw 64비트 값에서 1의 차이로 드러나니까요
function SameBits(A, B: Double): Boolean;
var
BitsA, BitsB: UInt64;
begin
Move(A, BitsA, SizeOf(BitsA));
Move(B, BitsB, SizeOf(BitsB));
Result := BitsA = BitsB;
end;
procedure CheckReopen(const FileName: string);
const
Expected: array[1..3] of Double =
(9007199254740991.0, -1000000000000001.0, -1000000000000001.0);
var
Book: TXLSXWorkbook;
Row: Integer;
Stored: Double;
begin
Book := TXLSXWorkbook.Create;
try
if Book.Open(FileName) <> lxOk then
raise Exception.Create('Open failed');
for Row := 1 to 3 do
begin
Stored := Book.Sheets[1].Cells[Row, 1].Value;
if not SameBits(Stored, Expected[Row]) then
Writeln('row ', Row, ' drifted: ', FloatToStrF(Stored, ffGeneral, 17, 0));
end;
finally
Book.Free;
end;
end;
3행이 흥미로운 행입니다. 재계산 없이 INT(-1E15-0.1)의 캐시된 결과를 읽으므로, 수식 엔진이 값을 다시 계산할 수 있다는 것이 아니라 캐시 자체가 무손실임을 증명합니다. 파이프라인에서 캐시된 결과에 의존한다면, 재계산 없이 캐시된 수식 값 읽기 별도 가이드가 캐시 상태들과 캐시가 없으면 절대 지어내지 않는 이유를 다룹니다
어느 다른 XLSX 파트가 숫자 캐시를 실리나
셀 라이터를 고치는 것은 여러 경로 중 하나를 닫은 것이고, 남은 각 숫자 쓰기 지점은 제각각 확인해야 했습니다. 첫 릴리스는 워크시트 값과 수식 캐시, 피벗 출력의 값 캐시를 포함해 커버했습니다. 피벗 소스 캐시와 차트 캐시는 여전히 15자리 깔때기를 통과했고, 가정이 아니라 두 차례의 후속 릴리스로 닫혔습니다
- 피벗 소스 캐시:
sharedItems값과 인라인 및 스트리밍cacheRecords라이터 둘 다 이제 같은 17자리 불변식을 쓰고, 피벗 읽기 경로는 같은 정확한 중점 수정을 적용합니다.refreshedDate같은 서술 속성은 원래 표기를 유지합니다 - 차트 캐시: 캐시된 점 값을 만드는 단일 쓰기 지점이 17자리로 전환했고, 추세선 값과 카테고리, 오차 막대 검증, 두 렌더링 경로를 커버하는 여섯 읽기 지점은 모두 하나의 exact-parse 헬퍼를 거칩니다
- 컬럼 폭과 축 경계 같은 그 외 모든 숫자는 여전히 무한과 subnormal 값을 0으로 흘려보내는 방어적 포매터를 거칩니다. 직렬화된 파트는 파싱 불가능한 float를 절대 담아서는 안 되기 때문입니다
가장 중요했던 상호 운용성 검사는 Excel 자신과의 것이었습니다. Excel은 253-1을 정수 9007199254740991로 쓰고, HotXLS는 동등한 17자리 표기 9007199254740990.7을 쓸 수 있습니다. 두 토큰은 양쪽에서 같은 double로 파싱되므로, Excel에서 만들고 HotXLS가 다시 열어 재저장한 뒤 Excel에서 다시 연 파일은 피벗 캐시의 같은 값을 유지합니다. 테스트할 성질은 동일한 표기가 아니라 상호 인식입니다
알아 둘 가치가 있는 한계와 경계 사례
여기서 무손실은 binary64 값이 보존된다는 뜻이지 모든 값이 표현 가능하다는 뜻이 아닙니다. 무한 결과는 여전히 #NUM! 오류로 저장되고 subnormal은 이전과 정확히 같이 0으로 저장됩니다. 표시도 별개의 문제입니다. Excel은 화면에 유효숫자 15개를 계속 보여 주므로, Excel에 9007199254740993을 입력한 사용자는 여전히 다른 저장 값을 얻으며, 어느 라이터도 그것을 고칠 수 없습니다. 통합 문서가 Set precision as displayed를 쓴다면 값은 의도적으로 숫자 서식으로 반올림되는데, 이는 이 글의 주제와 정반대입니다. 그 모드의 규칙은 HotXLS가 Excel 표시 정밀도 반올림을 적용하는 방법이 다룹니다
정확 리더는 Val을 대체하지도 않습니다. Val이 이미 받아들인 값만 바로잡습니다. RTL이 거부하는 토큰은 여전히 거부되고, 48자, 17자리, 지수 324 한계 밖의 토큰은 레거시 파서가 만든 것을 유지합니다. HotXLS나 Excel이 쓴 파일에서는 문제될 일이 없습니다. 어느 쪽도 셀 값에 그런 토큰을 만들지 않기 때문입니다. 그러나 유효숫자 40개의 손으로 만든 XML 파트는 정확 수정이 아니라 RTL의 정확도로 읽힙니다
FAQ
HotXLS는 셀에 대해 Excel이 표시하는 숫자를 바꾸나요?
아니요. HotXLS는 저장된 <v> 토큰에 몇 자리가 들어가는지만 바꿉니다. Excel은 여전히 유효숫자 15개를 표시하므로 셀은 똑같아 보입니다. 달라진 것은 밑바닥의 double이 이제 여러분의 Delphi 코드가 대입한 것과 일치한다는 점입니다
17 대신 그냥 16자리를 쓰면 안 되나요?
16자리는 모든 binary64 값에 충분하지 않습니다. 어떤 double은 십진 표기가 유일해지기까지 유효숫자 17개가 필요하므로, 16자리 라이터는 드문 경우에 여전히 서로 다른 두 double을 같은 텍스트로 매핑합니다. 이 변경이 제거하는 조용한 표류의 정확히 그 부류입니다
수정 전에 쓰인 파일은 다시 만들어야 하나요?
15자리보다 많은 자리가 필요했던 값을 담고 있는 경우에만 그렇습니다. 그 토큰들은 이미 디스크에서 마지막 자리들을 잃었고, 더 정확하게 읽는다 해도 쓰인 적 없는 것을 복원할 수는 없습니다. 원본 데이터에서 재저장하거나 수식을 재계산해 다시 저장하면 정확한 파일이 나옵니다
17자리 숫자 출력, 정확한 binary64 읽기 수정, 맞춤 피벗과 차트 캐시 처리는 Delphi, C++Builder, Lazarus용 HotXLS Delphi Spreadsheet Component의 일부이며, Excel이나 OLE 자동화가 설치되지 않은 상태에서 XLSX를 읽고 씁니다