HotXLS는 [MS-XLS] §2.5.129 FontIndex가 정의하는 대로 모든 BIFF8 폰트 참조에 번호를 매깁니다. 0부터 3까지는 zero-based, 4보다 큰 값은 one-based이고 4는 절대 나오지 않으므로, 다섯 번째 FONT 레코드가 ifnt 5이고 유효한 최대 ifnt는 FONT 레코드 개수와 같습니다. HotXLS 2.384.4부터 XF 작성기, XF 리더, rich text 문자열 run, 워크북 간 run 마이그레이션이 모두 이 규칙을 따르며, 2.384.5와 2.384.6은 이를 주석과 텍스트 상자 run까지 확장합니다. 복사와 행 삽입을 거치는 경우도 포함해서요
이 규칙은 직접 부딪혀 보기 전까지는 오타처럼 보입니다. 누군가 FONT 레코드 여덟 개를 가진 워크북을 열고, 폰트 8을 가리키는 XF를 발견한 뒤, 작성기가 범위를 벗어난 인덱스를 만들었다고 결론 내립니다. 정확히 이 추론이 HotXLS 2.384.1에 "수정"으로 실렸고, 올바른 구현을 Excel이 연 파일의 모든 커스텀 폰트가 한 슬롯 앞에 놓이는 구현으로 바꿔 놓았습니다. 흥미로운 부분은 off-by-one 자체가 아니라, BIFF8 라이브러리 안에 같은 관례가 얼마나 많은 곳에 살고 있는가, 그리고 폰트 바인딩이 한 번의 저장은 버티고 두 번째 저장에서 깨질 수 있다는 점입니다. BIFF8 XLUnicodeString cch와 fHigh 디코딩에서 다룬 길이와 인코딩의 기상천외한 부분을 이미 겪어 본 사람이라면, 이것도 같은 계열의 버그입니다. 파일은 멀쩡하고 산술이 틀렸을 뿐입니다
[MS-XLS] FontIndex 규칙은 실제로 무엇을 말할까요?
[MS-XLS] §2.5.129는 4 미만의 FontIndex는 zero-based 레코드 위치, 4 초과의 FontIndex는 one-based 레코드 위치이며 값 4는 절대 사용해서는 안 된다고(MUST NOT) 말합니다. 같은 FontIndex 타입이 XF 레코드, SST 서식 run, TXO 서식 run이 모두 사용하므로, 규칙을 한 번 잘못 읽으면 셋 다 오염됩니다. Excel이 만든 파일로 증거를 쉽게 재현할 수 있습니다. Office에 포함된 SOLVSAMP.XLS는 FONT 레코드 19개에 XF ifnt 최댓값이 19이고, 레코드 43개짜리 워크북은 43에서 최댓값에 달하며, Excel 16이 저장한 FONT 레코드 30개짜리 파일은 Courier New 셀을 22번째 레코드인 ifnt 22로 가리킵니다. 어디에도 4는 없습니다. 진단 도구에서 매핑을 직접 분석해야 한다면 변환은 두 개의 짧은 함수면 충분합니다
// [MS-XLS] 2.5.129 FontIndex: 0..3은 zero-based, > 4는 one-based, 4는 invalid
function FontIndexToRecordNo(Ifnt: Word): Integer; // 1 기반 FONT 레코드
begin
if Ifnt < 4 then
Result := Ifnt + 1
else if Ifnt > 4 then
Result := Ifnt
else
Result := -1; // 4는 나와서는 안 됩니다
end;
function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
if RecordNo <= 4 then
Result := RecordNo - 1
else
Result := RecordNo;
end;
HotXLS 내부에서 같은 규칙은 거울상인 두 곳에 삽니다. TXLSFontList.GetSaveIndex는 참조 목록에서 폰트의 1 기반 위치를 받아 1부터 4까지의 위치만 감소하므로, 위치 5는 ifnt 5로 기록됩니다. TXLSReader.ParseXF는 로드할 때 역을 수행합니다. ifnt가 5 이상이면 zero-based 폰트 목록 슬롯으로 감소하고, 그 아래는 그대로 둡니다. SST rich-run 리매핑과 CountRichRunFontRefs도 같은 ifnt >= 5 변환을 적용합니다. 요점은 하나의 관례가 모든 소비자에 적용된다는 것입니다
// TXLSFontList.GetSaveIndex (작성기 쪽)
Result := inherited GetSaveIndex(Index); // 1 기반 참조 위치
if (Result > 0) and (Result < 5) then
Dec(Result); // 1..4는 0..3이 되고, 5 이상은 그대로
// TXLSReader.ParseXF (리더 쪽)
fnti := Data.GetWord(0);
if fnti >= 5 then
Dec(fnti); // ifnt 5는 폰트 목록 슬롯 4
zero-based "수정"이 모든 커스텀 폰트를 한 칸 밀어 낸 이유는?
HotXLS 2.384.1의 zero-based 재작성은 1 기반 인덱스를 0 기반으로 읽고 나서 네 곳의 호출 지점을 그 잘못된 읽기에 맞춰 바꾼 탓에 모든 커스텀 폰트를 한 칸 밀었습니다. 바꾼 곳은 GetSaveIndex, ParseXF, SST run 리매핑, 그리고 Sheets.AddCopy의 워크북 간 run 마이그레이션입니다. HotXLS 자체 왕복 전송은 여전히 멀쩡해 보였습니다. 작성기와 리더가 서로 합의하고 있었으니까요. Excel은 합의하지 않았습니다. 2.384.1이 쓴 파일은 첫 커스텀 폰트를 ifnt 4에 놓았고 Excel은 이를 기본 폰트로 취급하며, 이후의 모든 커스텀 폰트는 한 레코드 앞에 놓였습니다. Excel 파일을 여는 쪽은 반대로 어긋나 각 폰트를 한 레코드 늦게 바인딩했습니다
변경을 애초에 멈춰야 했던 단서는 같은 코드베이스 안에 있었습니다. CountRichRunFontRefs, 차트 FONTX와 FBI 리매핑, 스타일 엔진 폰트 목록은 건드리지 않고 skip-4를 그대로 썼으므로, 2.384.1이 착지하자마자 라이브러리는 스스로 모순됐습니다. rich-text 폰트가 보통 어떤 XF에 의해서도 참조되는 우연 덕에 모순은 숨겨져 있었을 뿐입니다. 하나의 관례가 일곱 곳에 나타나는데 네 곳을 바꾸고 있다면, 나머지 셋을 의심하기 전에 자기 변경을 의심하세요. v2.384.4는 네 곳 모두에서 스펙 번호 매기기를 복원했고, ifnt < FontCount를 단언하며 그 잘못된 읽기를 굳혀 온 옛 회귀 테스트는 기록된 모든 ifnt를 스펙 공식으로 FONT 레코드 이름에 역매핑하는 테스트로 교체됐습니다. 솔직한 한계는 하나 남습니다. 2.384.1부터 2.384.3이 폰트 다섯 개 이상으로 저장한 파일은 리더가 유효한 데이터와 구별할 수 없는 밀린 인덱스를 지니므로, 유일한 처방은 다시 생성하는 것입니다
주석 폰트 run이 두 번째 저장에서만 깨지는 이유는?
주석과 텍스트 상자 run이 두 번째 저장에서 깨진 이유는 HotXLS가 첫 N-1개 FONT 레코드를 무조건 유지하고 어떤 XF도 참조하지 않을 때 마지막 하나만 드랍하는 반면, TXO 서식 run([MS-XLS] §2.4.329)은 번호를 다시 매기지 않고 바이트 그대로 되썼기 때문입니다. Excel이 만든 .xls 파일은 항상 참조되지 않는 꼬리 폰트(중국어 로캘 시스템에서는 9pt DengXian)로 끝나므로, 첫 저장에서 주석 run만이 쓰는 폰트는 마지막이 아니었고 눈에 보이는 변화는 없었습니다. 그런데 그 첫 저장이 꼬리 폰트를 드랍하고 주석 전용 폰트를 마지막 위치로 승격시켰습니다. 두 번째 저장은 그 폰트를 참조 없는 것으로 폐기했고, run의 ifnt는 끝을 넘어 가리켰으며, Excel은 기본 폰트로 폴백했습니다. 그 사이 워크북이 새 폰트를 얻었다면 run은 조용히 그 폰트에 묶이고, 테스트에서는 서식 있는 텍스트 상자 run이 Arial로 변하는 모습으로 나타났습니다. 주석과 하이퍼링크 검토 워크플로 만들기에서 설명한 것 같은 주석 많은 파일이 정확히 이 버그가 물리는 자리입니다. 열고, 주석 달고, 저장하는 일이 반복되니까요
HotXLS 2.384.5는 TXO run을 SST run처럼 다룹니다. CountRichRunFontRefs는 이제 모든 워크시트의 모든 TMSOShapeTextBox를 순회하며 각 run의 skip-4 ifnt를 슬롯으로 변환해 참조로 세므로, run만이 쓰는 폰트도 저장 필터를 살아남습니다. 만들어진 슬롯-저장 인덱스 테이블은 각 드로잉의 FontRunRemap에 들어가고, TMSOShapeTextBox.Store는 raw run 바이트의 사본 위에서 run 인덱스를 다시 씁니다. 폰트를 실지 않은 꼬리 TxOLastRun은 그대로 둡니다. 애플리케이션 코드의 계약은 단순합니다. TXLSComment.TextRuns.FontIndex와 TXLSTextBox.TextRuns.FontIndex는 읽은 그대로 4를 건너뛴 파일 번호를 사용하고, run 인덱스는 1 기반이며 CharIndex는 run이 시작되는 문자 오프셋입니다. 저장 후에는 저장된 번호가 설정한 번호와 다를 수 있지만, 여전히 같은 폰트를 가리킵니다
var
Book: IXLSWorkbook;
Note: TXLSComment;
I: Integer;
Ifnt: Word;
begin
Book := TXLSWorkbook.Create;
if Book.Open('review-notes.xls') <> 1 then
raise Exception.Create('Cannot open review-notes.xls');
Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
if Note <> nil then
for I := 1 to Note.TextRuns.Count do
begin
Ifnt := Note.TextRuns.FontIndex[I]; // 파일 번호, 4 건너뜀
if Ifnt = 4 then
raise Exception.CreateFmt('Run %d uses invalid ifnt 4', [I]);
Writeln(Format('run %d at char %d: ifnt %d = FONT record #%d',
[I, Note.TextRuns.CharIndex[I], Ifnt, FontIndexToRecordNo(Ifnt)]));
end;
end;
복사, 행 삽입과 워크북 간 run 마이그레이션
HotXLS 2.384.6부터 모든 클래식 엔진 복사 경로는 주석 서식 run을 유지합니다. Range.Copy, CopyRange, Sheets.AddCopy와 Range.Insert, Range.Delete 뒤의 셀 이동이 모두 TXLSRange.CopyCell을 거치는데, CopyCell은 주석 텍스트와 작성자만 복사하곤 했습니다. shift는 복사에 지우기가 더해진 것이므로, run 두 개짜리 노트 위에 행 하나를 삽입하면 run 0개와 폰트 하나가 남았습니다. 수정은 각 run을 복사하고 폰트를 TXLSWorkbook.MigrateRunFontIndex로 옮깁니다. skip-4 인덱스를 슬롯으로 변환하고, 대상 폰트 테이블로 값을 기준으로 폰트를 마이그레이션한 뒤 파일 번호로 역변환합니다. Sheets.AddCopy의 SST rich-text 마이그레이션도 자체 산술 사본을 들고 다니는 대신 같은 함수를 호출합니다. 두 가지 엣지 케이스가 함께 따라왔습니다. 원본과 대상이 같은 주석인 제자리 붙여넣기는 읽기 전에 run을 지우면 안 되고, Sheets.AddCopy는 이제 저장된 셀 레코드가 없는 셀에 붙은 주석도 두 번째 패스에서 처리하는데, 예전에는 아예 건너뛰었습니다. 워크북 간 복사의 폰트 테이블 쪽은 워크북 간 복사와 수식 리바인딩에서 다룬 수식 쪽과 같은 by-value 논리를 따릅니다. XLSX 엔진에서는 복사 경로가 이미 run을 값으로 클론했고, 빈틈은 주석 부분 자체에 있었습니다. 리더가 rFont, strike, u, vertAlign을 무시하고 작성기는 u나 vertAlign을 내보내지 않았으므로, 이제 run은 저장과 다시 열기에서 대칭으로 살아남습니다
BIFF8 파일에서 폰트 인덱스는 어떻게 테스트해야 할까요?
폰트 인덱스는 저장 후 다시 열어 테스트하고, 가급적 여러 세대에 걸치며, 숫자 범위를 단언하는 대신 각 ifnt를 FONT 레코드에 역매핑하세요. 이 이야기의 모든 버그는 메모리 상 테스트를 통과했습니다. 2.384.1 회귀는 맞춤형 작성기-리더 쌍 속에 살았고, TXO 드리프트는 사이에 폰트 테이블 변화가 있는 두 번의 저장이 필요했으며, XLSX에서 사라진 주석 run은 다시 열어야만 나타났습니다. 유용한 하네스는 Excel이 만든 샘플을 열고, HotXLS로 두 번 저장하며, 저장 사이에 폰트를 더하거나 빼고, 나서 run 위치와 바이트 수준에서 각 ifnt 뒤의 폰트 이름을 검사합니다. 저장 전후의 FontIndex 값을 비교하지 마세요. 번호 재매기기는 정당합니다
procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
Book: IXLSWorkbook;
Note: TXLSComment;
RunCount: Integer;
SecondRunAt: Word;
begin
Book := TXLSWorkbook.Create;
Assert(Book.Open(SrcFile) = 1); // Excel이 만든 파일, C2에는 run 두 개
Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
RunCount := Note.TextRuns.Count;
SecondRunAt := Note.TextRuns.CharIndex[2];
Book.Sheets[1].Range['C2', 'C2'].Copy(Book.Sheets[1].Range['E5', 'E5']);
Book.Sheets[1].Range['C1', 'C1'].Insert(xlShiftDown); // C2는 C3로 이동
Assert(Book.SaveAs(OutFile) = 1);
Book := TXLSWorkbook.Create; // 다시 열기, 메모리는 절대 믿지 않습니다
Assert(Book.Open(OutFile) = 1);
Note := Book.Sheets[1].Range['C3', 'C3'].Comment;
Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
Assert(Note.TextRuns.CharIndex[2] = SecondRunAt);
Note := Book.Sheets[1].Range['E5', 'E5'].Comment;
Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
end;
Delphi나 C++Builder에서 클래식 XLS를 읽고 쓰면서 라이브러리의 수많은 폰트 소비자 중 누가 [MS-XLS] §2.5.129에 아직 맞는지 추적하고 싶지 않다면, 여기서 설명한 skip-4 번호 매기기, 저장 시 run 재번호 매기기, 값 기준 run 마이그레이션이 HotXLS Delphi 스프레드시트 컴포넌트에 내장되어 있습니다. Excel이나 OLE automation 없이 XLS와 XLSX를 읽고 씁니다