Technical Article

Delphi에서의 유니코드 안전한 스프레드시트 내보내기: RTF 및 HTML

스프레드시트에 고객 이름 열이 있습니다. 일부는 중국어, 일부는 키릴 문자, 일부는 독일어 움라우트나 프랑스어 악센트 기호를 포함하고 있습니다. 이를 CSV로 내보내 열어보면 모든 문자가 완벽하게 유지됩니다. 하지만 동일한 문서를 메일 머지(mail-merge) 템플릿용 RTF로 내보내 워드 프로세서에서 열어보면, 비ASCII 이름들이 온통 물음표(?)로 깨져 나타납니다. 데이터 내용 자체는 그대로이지만, 내보내는 출력 포맷의 인코딩 규약이 각각 다르기 때문에 발생하는 문제입니다.

이는 표면적으로는 완벽한 유니코드 지원 라이브러리처럼 보이는 도구에서 자주 발생하기 쉬운 오류입니다. 셀의 텍스트가 내부적으로는 WideString으로 정상 관리되므로 모델 내부에서는 데이터 손실이 일어나지 않습니다. 손실은 문서 저장 경계선인 라이터(writer) 단계에서 발생합니다. 허용되는 바이트 표준과 영역을 벗어나는 문자의 인코딩 규칙을 정의하는 개별 포맷으로 텍스트를 직렬화(serialize)하는 도중 유실되는 것입니다. 하나의 내보내기 모듈을 정상화했더라도, 다른 포맷을 처리할 때 규칙이 누락되면 여전히 텍스트가 깨져 출력됩니다. 이 문제를 일괄 해결할 수 있는 단일 글로벌 스위치는 존재하지 않습니다. 포맷 경로마다 개별적으로 정확한 인코딩 정책을 확립해야 합니다.

RTF는 설계상 7비트 안전 포맷

Rich Text Format(RTF)은 유니코드보다 먼저 개발되었으며, 출력 가능한 ASCII 문자만 전송할 수 있는 시스템에서도 정상 전달되도록 설계되었습니다. RTF 문서는 헤더에 사용할 코드 페이지를 선언하며, 라이터가 해당 코드 페이지 범위에서 표현할 수 없는 문자는 원시 바이트 대신 이스케이프 시퀀스로 변환하여 기록합니다. 이때 사용되는 지시어는 16비트 부호 있는 코드 유닛 정보를 수반하는 \u이며, 이스케이프 형식을 인식하지 못하는 오래된 구형 리더기를 위한 ASCII 대체 문자(fallback character)도 뒤에 함께 기록됩니다.

HotXLS는 이 구조로 RTF를 작성합니다. 문서 헤더에 \ansi\ansicpg1252\uc1 형태로 코드 페이지를 선언하며, lxRTF 유닛의 라이터 모듈은 모든 문자열을 스캔하여 기본 ASCII 범위를 초과하는 임의의 문자를 \u 이스케이프 시퀀스로 변환합니다. 이를 통해 선언된 코드 페이지 내용에 영향을 받지 않고 데이터가 항상 7비트 클린(clean) 상태를 유지하게 합니다. 예를 들어 U+4E2D 같은 코드 포인트는 뷰어 시스템의 설정 코드 페이지로 멋대로 해석될 여지가 있는 원시 바이트가 아닌 리터럴 시퀀스  3?로 기록됩니다. 이러한 예외 처리가 배제되면 선언된 코드 페이지 외부의 문자들은 바이트 표현법이 불명확해져, 라이터가 그대로 원시 바이트를 출력할 때 물음표(?)로 깨지는 결과를 낳게 됩니다.

기억해야 할 점은 헤더에 선언된 코드 페이지와 이스케이프 시퀀스가 하나의 세트로 작동한다는 사실입니다. 코드 페이지 선언만으로는 그 범위를 벗어나는 문자들을 보정할 수 없습니다. 또한 코드 페이지 선언 없이 이스케이프만 사용하면 대체 문자 기준이 모호해집니다. 두 처리가 모두 유기적으로 적용되어야 하며, 하나라도 누락되면 다국어가 포함된 스프레드시트를 정상 내보내기 할 수 없습니다.

HTML 이스케이프 처리는 꺾쇠괄호 변환 그 이상

HTML 내보내기 기능은 탐색 프레임에 시트 이름이 노출되는 다중 시트 문서를 생성합니다. 이 이름들은 사용자가 지정한 문자열로 구성되므로 마크업에서 기능어로 쓰이는 특수 문자들을 포함할 수 있습니다. 예를 들어 실제 이름이 Q1 & Q2 <draft>인 시트는 이스케이프 처리된 엔티티(entity) 형태로 페이지에 기록되어야 합니다. 그렇지 않으면 꺾쇠괄호(<, >)가 가상 태그로 인식되거나 앰퍼샌드(&)가 다른 엔티티 코드로 꼬여 오동작하게 됩니다. 이는 일반적인 HTML 이스케이프 처리이지만, 프레임 레이블 단계에서 이 처리를 누락하면 ASCII 기반 테스트 케이스만으로는 잡아내기 어려운 버그가 됩니다.

인코딩 검증 단계는 그보다 한 수준 아래에서 수행됩니다. 비ASCII 문자가 UTF-8 출력을 보장할 수 없는 컨텍스트에 배치될 때 가장 안전한 표현 방식은 숫자 문자 참조(numeric character reference)입니다. 즉, 의미 해석이 서버 문자셋 설정에 의존하게 되는 원시 바이트 대신 U+00E9를 é 형태로 변환하여 저장하는 방식입니다. 파일을 읽을 때도 동일한 규칙이 적용됩니다. XLSX에서 통합 문서를 역로드할 때 텍스트 저장 파일(shared strings)은 문자 코드가 숫자 XML 엔티티 형태로 보관되어 있을 수 있으며, 이 엔티티 정보는 셀 데이터 모델에 진입하기 전에 완전한 형태의 문자 데이터로 먼저 디코딩되어야 합니다. 디코딩 처리 도중 바이트 단위가 흐트러지면 단일 문자가 깨진 글자(mojibake)로 조각나서 이후 복구가 불가능한 상태가 됩니다.

XLSX 컨테이너는 ZIP이며 ZIP은 자체 이름 인코딩을 가짐

XLSX 파일은 기본적으로 ZIP 아카이브이며 내부 개별 문서 객체들의 경로 이름을 저장합니다. ZIP 사양은 개발 역사가 오래되어 초기 정의 파일명 인코딩에 대한 규약이 부재했습니다. 따라서 리더기는 별도의 표식이 없으면 아카이브 내부 파일명을 시스템의 로컬 코드 페이지로 자동 추정합니다. 하지만 다국어 시트명이 적용되거나 악센트 및 비라틴 문자가 포함된 외부 이미지 리소스 파일명이 유입되면 이러한 자동 추정 방식에 오류가 발생합니다.

해결책은 단일 비트 마킹입니다. 각 파일의 로컬 헤더에 정의된 범용 플래그 비트 11(general-purpose bit 11)은 내부 경로 파일명이 UTF-8로 인코딩되어 있음을 명시합니다. HotXLS는 아카이브 해제 시 정확히 이 비트를 검사하여 $0800 플래그 마스크 상태를 판별합니다. 이 검사를 건너뛰는 라이터나 리더기는 정상적으로 UTF-8 저장된 파일명 정보를 꼬이게 만들어 오류를 유발합니다. 아주 가벼운 플래그 처리이지만, 내부 파일 데이터가 파싱되기 전에 파일명의 유실 및 손상을 방지하여 변환 무결성을 유지하는 역할을 합니다.

대소문자 통합 및 숫자 스캔에 숨겨진 동일한 위험 요소

수식 계산 단계는 직렬화를 넘어 문자 비교 시 유니코드 안전성이 요구되는 영역입니다. SEARCH 함수는 대소문자를 구분하지 않으므로 검색을 수행하기 전에 텍스트의 대소문자를 일치(case folding)시켜야 합니다. 이때 ANSI 코드 페이지 방식으로 대문자 변환을 실행하면 다국어 문자가 좁은 영역의 코드 페이지 한계에 갇혀 데이터가 훼손됩니다. 올바른 방법은 전체 UTF-16 범위를 보존하는 와이드 문자열 대문자 변환(wide-string uppercasing)입니다. HotXLS는 이 문제를 예방하기 위해 WideUpperCase를 적용하므로, 악센트 기호나 다국어 텍스트를 검색할 때 문자 훼손 없이 정확하게 일치하는 결과를 반환합니다.

수식 토크나이저(tokenizer) 역시 문자와는 관계없지만 토큰의 종결 지점 식별과 관련해 유사한 예외 처리가 필요합니다. 1E3이나 2.5E-3 같은 지수 표기법은 단일 숫자 리터럴이므로, 스캐너는 E와 옵션인 부호, 그리고 이어지는 자릿수를 하나의 숫자로 묶어 인식해야 합니다. 그렇지 않으면 문자와 숫자로 조각나서 분석 오류를 내거나 연산 값을 왜곡시킵니다. 이 역시 파서가 문자 단위의 경계 설정이 정확하게 제어해야 한다는 점에서 일치하는 기술 영역입니다: 하나는 대소문자 변환 기준이고, 다른 하나는 토큰의 연속성 판별입니다.

다국어 통합 문서 생성 및 내보내기

공개 API 단에서는 이 모든 상세 제어 로직을 다룰 필요가 없습니다. WideString 셀 값을 입력하여 통합 문서를 구성한 후 원하는 내보내기 명령을 지시하면 됩니다. 인코딩 처리 과정은 각 포맷 라이터 내부에서 자동으로 수행됩니다. 아래 예제는 시트에 여러 언어의 텍스트를 저장한 후 동일한 파일에서 RTF 및 HTML 사본을 내보내는 예시입니다.

uses
  lxHandle;

procedure ExportMultilingualWorkbook;
var
  Book: IXLSWorkbook;
  Sheet: IXLSWorksheet;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Customers');

    Sheet.Cells[1, 1].Value := 'Name';
    Sheet.Cells[1, 2].Value := 'City';

    // Cell text is held as WideString, so every script survives the model.
    Sheet.Cells[2, 1].Value := '王伟';          // Chinese
    Sheet.Cells[2, 2].Value := '北京';
    Sheet.Cells[3, 1].Value := 'Müller';        // German umlaut
    Sheet.Cells[3, 2].Value := 'Köln';
    Sheet.Cells[4, 1].Value := 'Иванов';        // Cyrillic
    Sheet.Cells[4, 2].Value := 'Москва';
    Sheet.Cells[5, 1].Value := 'Désirée';       // French accents
    Sheet.Cells[5, 2].Value := 'Montréal';

    // RTF: the lxRTF writer declares the code page and emits every
    // non-ASCII character as a \u escape, keeping the file 7-bit clean.
    Book.SaveAsRTF('Customers.rtf');

    // HTML: sheet names are HTML-escaped and non-ASCII text is written
    // so it does not depend on a guessed response charset.
    Book.SaveAsHTML('Customers.html');
  finally
    Book := nil;
  end;
end;

두 호출은 모두 실행 결과 상태 정수(Integer) 값을 반환하며 메모리의 텍스트를 동일하게 활용합니다. 호출 코드 단에서 코드 페이지를 선언하거나 문자를 이스케이프 처리하지 않는데, 이는 각 라이터 모듈이 자기 포맷에 최적화된 연산을 직접 전담하기 때문입니다. 동일한 데이터 기반으로 구분자 기반 파일 조작이 필요하면 통합 문서 수준의 SaveAsCSV를 실행할 수 있습니다.

// Same workbook, a third export path with its own encoding rules.
Book.SaveAsCSV('Customers.csv');

유니코드 안전성은 라이브러리가 아닌 경로별로 보장

배울 수 있는 교훈은 유니코드의 안전성은 단 한 번의 단일 조치로 완결되지 않는다는 것입니다. RTF는 코드 페이지 선언과 \u 이스케이프 처리가 복합 구성되어야 합니다. HTML은 마크업 특수 문자의 엔티티 이스케이프와 인코딩 미보증 영역에서의 숫자 문자 참조, 그리고 로드 시 XML 엔티티 문자 해제 로직이 올바르게 맞물려야 합니다. ZIP 컨테이너는 내부 경로 파일명이 UTF-8로 저장되도록 범용 비트 11을 마킹해야 합니다. 수식 엔진은 와이드 문자열의 대소문자 변환과 지수 수치를 단일 토큰으로 처리하는 파서 규칙을 준수해야 합니다. 이들은 저마다 다른 방식으로 데이터를 연동하므로, 하나를 만족하더라도 다른 영역을 누락하면 여전히 물음표(?)가 섞인 손상된 파일을 반환받게 됩니다.

구분자 기반 내보내기 모듈의 기능 차이에 대해서는 CSV, TSV 및 HTML 내보내기 연습 가이드에서 상세 다루고 있으며, 원본 데이터가 시트가 아닌 데이터베이스 쿼리 결과인 경우 Delphi 보고서 작성을 위한 데이터베이스 내보내기 가이드를 통해 연계 처리를 확인할 수 있습니다. 이 모든 기술 명세는 리딩, 수식, 서식 지정 API와 함께 Delphi 및 C++Builder용 HotXLS 컴포넌트에 포함되어 제공됩니다.