기술 문서

HotXLS의 BIFF8 56색 팔레트와 OKLab 색 매핑

HotXLS는 임의의 RGB와 테마 색을 56슬롯 BIFF8 색 팔레트에 두 층으로 대응시킵니다. NearestIndexedColor는 OKLab 공간에서 지각상 가장 가까운 기존 팔레트 항목을 찾고, BuildBiffPalettePlan과 ApplyBiffPalettePlan은 남는 팔레트 슬롯을 다시 써서 트루컬러 통합 문서가 클래식 XLS 저장을 무사히 통과하게 합니다. 계기는 언제나 똑같은 지원 티켓입니다. 누군가 XLSX로 회사 네이비 헤더와 부드러운 청록 강조색을 넣어 보고서를 만들고, 레거시 소비자를 위해 .xls로 저장하면, 헤더는 새까맣게 돌아오고 청록은 요란한 터콰이즈가 됩니다. 크래시한 것도 없고 경고도 울리지 않았습니다. 구 형식의 색 모델이 새 형식이 기술한 것을 담지 못할 뿐이고, 라이브러리는 뭔가를 골라야만 했습니다

XLS 파일은 왜 56색만 담을 수 있을까요?

BIFF8 셀 서식은 RGB 값을 저장하지 않기 때문입니다. 폰트, 채우기, 테두리는 색 인덱스를 들고 있고, 통합 문서 전역의 Palette 레코드($0092, [MS-XLS] §2.4.188)가 인덱스 8부터 63까지 정확히 56개의 불투명 RGB 항목을 제공합니다. 인덱스 0부터 7은 여덟 기본색의 고정 복사본이고, 63을 넘는 값은 색이 아니라 시스템 전경, 시스템 배경, 차트 텍스트 같은 토큰입니다. HotXLS는 물리 인덱스에서 7을 뺀 1부터 56까지의 공개 ColorIndex로 팔레트를 노출하고, ResolveIndexedColor는 TXLSIndexedColorSpace로 세 넘버링 체계를 분리합니다. 1..56 API 값에는 xicsPublicColorIndex, 디스크에 있는 날 인덱스에는 xicsBiffIcv(전달한 역할에 따라 IcvFont, IcvXF, IcvChart 부분집합으로 검증), 그리고 64와 65가 시스템 전경과 배경을 뜻하는 xicsOoxmlIndexed입니다

HotXLS가 TXLSIndexedColorSpace로 세 인덱스 색 체계를 분리하는 방식: 날 BIFF icv 값, 여덟 기본색에 고정된 0부터 7, Palette 레코드 $0092의 8부터 63까지 56개 팔레트 슬롯, 시스템 전경 같은 63 초과 토큰, 7을 뺀 공개 ColorIndex 1부터 56, 그리고 64와 65가 시스템 전경과 배경을 뜻하는 xicsOoxmlIndexed
같은 색 인덱스도 체계마다 다른 숫자를 뜻하므로, HotXLS는 날 BIFF 토큰이 공개 ColorIndex로 둔갑하지 못하게 모든 값을 ResolveIndexedColor로 라우팅합니다
var
  Res: TXLSIndexedColorResolution;
begin
  // $40은 팔레트 슬롯이 아니라 BIFF icv 토큰
  Workbook.ResolveIndexedColor($40, xicsBiffIcv, Res);
  case Res.Kind of
    xickPalette:   UseArgb(Res.ARGB);   // 해석에 성공하면 팔레트 슬롯
    xickAutomatic,
    xickSystem:    UseSystemColor(Res.SystemColorRole);
    xickInvalid:   RejectToken(Res.RawIndex);
  end;
end;

예제가 Boolean 반환값은 무시하고 Res.Kind로 분기한다는 점에 주목하세요. ResolveIndexedColor는 구체적인 ARGB를 얻었을 때만 True를 반환하고, 짧은 오버로드는 Windows 데스크톱을 읽지 않으므로 automatic이나 system 토큰은 xickSystem으로 분류된 채 정당하게 False로 돌아옵니다. HotXLS도 자체 통합 문서 직렬화기에서 이것에 부딪혔습니다. False를 "색 없음"으로 취급하는 코드는 토큰이 지닌 Automatic과 System 의미를 조용히 버립니다. 이런 토큰의 실제 RGB 값이 필요하면 긴 오버로드를 호출해 자신만의 UI, 내보내기, headless 정책을 적용하는 TXLSTryResolveSystemColor 콜백을 넘기세요

HotXLS는 왜 RGB 대신 OKLab으로 색을 맞출까요?

sRGB 채널 값은 감마 인코딩되어 있어서 RGB의 유클리드 거리는 사람이 보는 것을 따라가지 못하고, 오차는 회사 팔레트가 좋아하는 바로 어둡고 채도 높은 톤에서 최악이 됩니다. 짙은 파랑 $000033을 봅시다. RGB에서 검은색까지 거리는 51이고 기본 네이비 항목 $000080까지는 77이라, RGB 매처는 자신만만하게 헤더를 검은색으로 칠합니다. OKLab에서는 제곱 거리가 검은색까지 약 0.0312, 네이비까지 약 0.0235라서 HotXLS는 네이비, 물리 슬롯 18의 ColorIndex 11을 고릅니다. 바로 이 사례가 Classic과 XLSX 엔진 양쪽의 테스트 스위트에 고정되어 있습니다. ArgbToOklab 내부의 변환은 각 sRGB 채널을 선형화하고, OKLab LMS 행렬을 적용하고, 세제곱근을 구해 L, a, b에 사영합니다. 그 뒤로는 단순 제곱 유클리드 거리가 지각 차이의 그럴듯한 대리값이 됩니다. OKLab은 CIEDE2000이 아니며 그렇다고 주장하지도 않지만, 구간별 색상 보정이 없고 색 하나당 곱셈 몇 번이면 끝나며, 클러스터링 루프를 돌리기에 충분히 안정적입니다. 이 점이 OKLab이 진짜 제 값을 하는 곳입니다

HotXLS가 짙은 파랑 $000033을 팔레트에 대응시키는 방식: 감마 인코딩된 RGB의 유클리드 거리는 검은색까지 51, 네이비까지 77로 측정되어 헤더를 검은색으로 칠하지만, ArgbToOklab의 제곱 거리 0.0312와 0.0235는 NearestIndexedColor가 네이비, 물리 슬롯 18의 ColorIndex 11을 고르게 합니다
감마 인코딩된 채널 값 때문에 RGB 거리는 사람이 보는 것의 믿을 만한 대리값이 못 되므로, HotXLS는 OKLab으로 한 번 변환하고 단순 제곱 유클리드 비교로 팔레트 스캔을 진행합니다

NearestIndexedColor는 무엇을 보장할까요?

NearestIndexedColor는 결정론적이고 읽기 전용인 답을 보장합니다. 입력 변환 한 번, 캐시된 56개 항목에 대한 고정 스캔 한 번, 그리고 두 항목이 똑같이 가까울 때는 항상 더 낮은 공개 인덱스입니다. 각 통합 문서는 팔레트 세대 카운터와 함께 56개 물리 슬롯 전부의 정규화된 ARGB와 OKLab 좌표를 캐시합니다. 팔레트 리셋은 캐시를 다시 만들고, 단일 슬롯 변경은 그 슬롯만 갱신하며, 낡은 세대에 대한 질의는 추측하는 대신 False를 반환합니다. 스캔은 슬롯 8부터 시작하는 엄격한 미만 비교를 쓰는데, 그래서 같은 색을 두 번 담은 팔레트는 항상 더 낮은 인덱스로 답합니다. 생성된 파일 두 개를 diff하고 바이트 단위로 동일한 출력을 기대할 때 이것이 중요합니다. 입력 알파는 좁은 계약을 따릅니다. 알파 바이트가 0이면 불투명으로 취급하고, 부분 투명 값은 ColorIndex 0과 PaletteSlot -1로 거부됩니다. 팔레트 항목에는 알파가 없기 때문입니다. Classic 엔진의 채우기와 테두리 라이터는 저장 시 같은 OKLab 매칭 루틴으로 RGB와 테마 색을 인덱스로 변환하므로, API와 저장된 파일이 색이 어느 슬롯에 떨어지는지에 대해 일치합니다

var
  Match: TXLSNearestIndexedColorMatch;
begin
  if Workbook.NearestIndexedColor($FF000033, Match) then
  begin
    // 결과: Match.ColorIndex = 11, Match.PaletteSlot = 18, Match.ARGB = $FF000080
    if not Match.ExactMatch then
      LogApproximation(Match.InputARGB, Match.ARGB, Match.DistanceSquared);
  end;
end;

BuildBiffPalettePlan은 트루컬러를 56슬롯에 어떻게 넣을까요?

BuildBiffPalettePlan은 통합 문서를 건드리지 않고 56개 슬롯 전체에 대한 완전한 제안을 계산하므로, 검토하고 로그로 남기거나 버릴 수 있습니다. 플래너는 먼저 ScanIndexedColorUsage를 호출합니다. 폰트, 채우기, 테두리, 조건부 서식, 도형, 주석, 워크시트 눈금선이 인덱스로 참조하는 슬롯은 전부 잠깁니다. 팔레트 항목을 바꾸면 그 인덱스를 쓰는 모든 곳의 색이 한꺼번에 바뀌기 때문입니다. 대상은 폰트, 채우기, 테두리, differential 스타일, 데이터 막대, 색 스케일에서 오는 직접 RGB와 해석된 테마 색입니다. 각 대상은 렌더링 참조 횟수와 정의 횟수 중 큰 값으로 가중치를 받고, 조건부 서식은 자기 범위가 덮는 셀 수를 세므로, 열 전체에 칠해진 색이 주석 하나에 쓰인 색보다 무겁습니다. 배치는 이어서 고정된 순서로 진행됩니다:

  • 잠긴 슬롯은 소스 색을 무조건 유지합니다
  • 팔레트에 이미 존재하는 대상은 가장 낮은 매칭 슬롯에 유지되고 그 슬롯은 고정됩니다
  • 남은 고유 대상이 여유 슬롯에 들어맞으면 각각 정확한 슬롯을 받고, ARGB 오름차순으로 배정됩니다
  • 그렇지 않으면 Quantized가 설정되고, 각 여유 슬롯은 가장 가까운 기존 중심까지의 거리에 가중치를 곱한 값이 가장 큰 대상으로 시드되며, OKLab에서 주파수 가중 k-means를 최대 16라운드 돌려 배정이 바뀌지 않을 때까지 여유 중심만 움직입니다

오버플로 경로가 주는 것에 대해서는 냉정하게 봐야 합니다. 클러스터링은 전역 최적이 아니라 경계가 있는 국소 최적화이고, 여유 슬롯은 최종적으로 클램프까지 적용해 sRGB로 되돌린 중심점을 담는데, 그 색은 어느 셀도 그대로 쓴 적 없는 색일 수 있습니다. 그래도 얻는 것은 재현성입니다. 같은 통합 문서는 언제나 같은 플랜을 내놓고, 플랜은 WeightedError, MaxDistanceSquared, ExactTargetWeight, TotalTargetWeight로 자기 손상을 보고하므로, 근사가 브랜드 가이드라인에 비해 너무 거칠어지면 배치 작업이 저장을 거부할 수 있습니다

트루컬러 통합 문서의 HotXLS 팔레트 파이프라인: ScanIndexedColorUsage가 폰트, 채우기, 테두리, 조건부 서식, 도형, 주석, 눈금선이 참조하는 모든 슬롯을 잠그고, BuildBiffPalettePlan이 정확한 색을 ARGB 오름차순으로 배치하거나 OKLab에서 주파수 가중 k-means를 최대 16라운드 돌리며, ApplyBiffPalettePlan이 쓰기 전에 세대와 FNV-1a 해시를 검증합니다
플래닝은 읽기 전용이며 재현 가능하고, 플랜은 WeightedError와 MaxDistanceSquared로 자기 손상을 보고하며, 플랜은 사실상 일회용이라 낡은 플랜은 팔레트를 건드리지 않은 채 거부됩니다
var
  Plan: TXLSBiffPalettePlan;
  I: Integer;
begin
  Plan := Workbook.BuildBiffPalettePlan;   // 읽기 전용
  if Plan.Quantized and (Plan.MaxDistanceSquared > MaxAcceptedError) then
    raise Exception.Create('Too many distinct colors for a BIFF8 palette');
  for I := 0 to High(Plan.Slots) do
    if Plan.Slots[I].Changed then
      LogSlot(Plan.Slots[I].ColorIndex, Plan.Slots[I].SourceARGB,
        Plan.Slots[I].TargetARGB);
  if not Workbook.ApplyBiffPalettePlan(Plan) then
    raise Exception.Create('The palette changed after planning');
end;

ApplyBiffPalettePlan은 낡은 플랜을 어떻게 거부할까요?

ApplyBiffPalettePlan은 슬롯 하나 쓰기 전에 플랜 전체를 검증하고, 현재 통합 문서와 하나라도 어긋나면 팔레트를 건드리지 않은 채 False를 반환합니다. 플랜은 SourcePaletteGeneration과 SourcePaletteHash, 즉 56개 소스 색에 대한 64비트 FNV-1a 해시를 들고 있습니다. 검증은 또한 모든 공개 및 물리 인덱스와 모든 소스 색을 다시 확인하고, 잠긴 슬롯이 changed로 표시되지 않았는지, 잠김과 변경 개수, 그리고 모든 대상이 불투명한지도 확인합니다. 그 사이에 일어난 유효한 팔레트 변경은 같은 플랜의 이전 적용 성공을 포함해 플랜을 낡게 만들므로, 플랜은 사실상 일회용입니다. 바뀐 슬롯이 없는 유효한 플랜은 세대를 진행하지 않고 성공하고, 실제 변경은 세대를 한 번 올리고 OKLab 매처를 한 번 다시 만드는데, Classic 엔진은 고정 팔레트 배열을 다시 쓰고 XLSX 엔진은 준비된 인덱스 색 오버라이드 목록으로 갈아끼웁니다

BIFF8 저장과 XLSX-to-XLS 변환에서 켜기

BiffPaletteSavePolicy 속성의 기본값은 xbpsPreserve라서, HotXLS를 업그레이드한다고 누군가의 팔레트가 등 뒤에서 다시 쓰이는 일은 없습니다. xbpsOptimizeTrueColors로 설정하면 Classic 통합 문서가 SaveAs 안에서 새 플랜을 만들어 적용하지만, 대상 형식이 xlExcel97일 때뿐입니다. BIFF5, CSV, HTML, PDF, XLSX 등 다른 라이터는 이 설정을 무시합니다. 저장에 성공하면 최적화된 팔레트가 통합 문서 모델에 남아 이후 질의와 저장이 같은 매핑을 봅니다. 저장이 실패하거나 취소되면 원래 56색과 원래 세대가 복원됩니다. XLSX 소스의 경우 lxXlsxExport의 SaveXLSXWorkbookAsXLS가 로드한 통합 문서에서 플랜 하나를 만들어 어떤 스타일이 변환되기 전에 대상 팔레트에 쓰는데, 통합 문서 감사 및 변환 워크벤치 데모가 실행하는 결정론적 다리가 바로 이것입니다. 테마 색은 틴트가 RGB로 해석된 뒤 같은 플래너를 통과합니다. 차트 채우기에서 테마를 살아 있는 채로 두고 싶다면 GelFrame 테마 색 차트 채우기 글이 바이너리 XLS가 평탄화된 색 대신 스킴 인덱스를 저장하는 방식을 다룹니다

// Classic 통합 문서: 옵트인, BIFF8 전용
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
  HandleSaveFailure;   // 팔레트는 이미 복원됨

// 결정론적 팔레트 플랜 하나로 XLSX 모델을 BIFF8로 저장
XWorkbook := TXLSXWorkbook.Create;
try
  if XWorkbook.Open('report.xlsx') = 1 then
    SaveXLSXWorkbookAsXLS(XWorkbook, 'report.xls');
finally
  XWorkbook.Free;
end;

HotXLS 팔레트 API는 IXLSWorkbook과 TXLSXWorkbook에서 똑같이 동작하며, Delphi와 C++Builder 어느 쪽에서도 마찬가지입니다. HotXLS Delphi Excel 컴포넌트 페이지에서 평가판을 내려받아 여러분의 가장 알록달록한 스프레드시트에 겨눠 보세요