스프레드시트에 고객 이름 열이 있습니다. 일부는 중국어, 일부는 키릴 문자, 일부는 독일어 움라우트 또는 프랑스어 억양을 포함합니다. 이를 CSV로 내보내고 결과를 열면 모든 문자가 온전합니다. 메일 병합 템플릿을 위해 동일한 통합 문서를 RTF로 내보내고 워드 프로세서에서 열면 비-ASCII 이름이 물음표 행으로 깨져 있습니다. 데이터는 결코 변경되지 않았습니다. 변경된 것은 작성한 형식의 인코딩 계약이며, 각 내보내기 경로마다 다릅니다
이것이 겉보기에 유니코드를 완전히 지원하는 것처럼 보이는 라이브러리를 잡는 함정입니다. 셀 텍스트는 내부적으로 WideString으로 보관되므로 모델은 문자를 결코 잃지 않습니다. 손실은 경계에서 발생하며, 어떤 바이트가 유효한지 그리고 유효한 범위를 벗어나는 모든 것을 어떻게 인코딩해야 하는지에 대한 자체 규칙이 있는 형식으로 해당 텍스트를 직렬화해야 하는 작성기(writer)에서 발생합니다. 한 작성기를 올바르게 작성하더라도 여전히 동일한 텍스트를 훼손하는 다른 작성기를 배포할 수 있습니다. 수정 사항은 전역 스위치가 아닙니다. 모든 경로에서 분리되고 올바른 결정을 내려야 합니다
RTF는 설계상 7비트 안전 형식입니다
서식 있는 텍스트 형식(Rich Text Format)은 유니코드보다 오래되었으며 인쇄 가능한 ASCII만 통과시키는 전송에서 살아남도록 지정되었습니다. RTF 문서는 헤더에 코드 페이지를 선언하며, 작성기가 해당 코드 페이지로 나타낼 수 없는 모든 문자는 원시 바이트(raw byte)가 아니라 이스케이프(escape)로 방출되어야 합니다. 관련된 이스케이프는 \u로, 부호 있는 16비트 코드 유닛(code unit)과 함께 이스케이프를 전혀 이해하지 못할 정도로 오래된 리더를 위한 ASCII 대체 문자가 뒤따릅니다
HotXLS는 이런 방식으로 RTF를 작성합니다. 문서 헤더는 \ansi\ansicpg1252\uc1 형태로 코드 페이지를 선언하는 것으로 시작하며, lxRTF 유닛의 작성기는 일반 ASCII 이상의 모든 문자를 \u 이스케이프로 방출하며 모든 문자열을 순회하므로 바이트 스트림은 선언된 코드 페이지가 수용할 수 있는 것과 무관하게 7비트로 깔끔하게 유지됩니다. U+4E2D와 같은 코드 포인트는 \u20013?라는 리터럴 시퀀스가 되며, 뷰어가 가정하게 되는 임의의 코드 페이지를 통해 해석하려고 시도하는 원시 바이트가 아닙니다. 이러한 규율이 없으면 선언된 코드 페이지 외부의 모든 것은 합법적인 바이트 표현을 가지지 못하고, 원시 값을 방출하는 작성기는 이 문서에서 시작된 물음표를 생성합니다
명심해야 할 세부 사항은 선언된 코드 페이지와 이스케이프가 단일 계약의 두 반쪽이라는 것입니다. 코드 페이지 선언 자체로는 그 범위 밖의 텍스트에 도움이 되지 않습니다. 선언된 코드 페이지 없이 이스케이프를 방출하면 대체 문자가 모호해집니다. 두 가지가 함께 올바르게 이루어져야 하며, 어느 하나만 처리하는 작성기가 첫 번째 다국어 통합 문서에서 여전히 실패하는 이유가 바로 이 때문입니다
HTML 이스케이핑은 꺾쇠 괄호 그 이상입니다
HTML 내보내기는 내비게이션 프레임이 시트 이름을 시각적 텍스트로 전달하는 다중 시트 문서를 생성합니다. 이 이름들은 마크업에서 중요한 문자를 포함하여 모든 문자를 포함할 수 있는 작성자 제어 문자열입니다. 시트 이름이 말 그대로 Q1 & Q2 <draft>인 경우 이스케이프 처리된 엔티티로 페이지에 전달되어야 합니다. 그렇지 않으면 꺾쇠 괄호가 보이지 않는 태그를 열고 앰퍼샌드가 전혀 의도하지 않은 엔티티 참조를 시작하게 됩니다. 이것은 일반적인 HTML 이스케이핑이며, 프레임 레이블에서 이를 건너뛰는 것은 ASCII 전용 시트 이름으로 만들어진 모든 테스트를 통과하는 종류의 누락입니다
인코딩 문제는 그보다 한 계층 아래에 있습니다. 비-ASCII 문자가 UTF-8로 제공된다고 보장할 수 없는 컨텍스트에 놓일 때, 안전한 표현은 숫자 문자 참조(numeric character reference)이므로 U+00E9는 응답 문자 셋에 따라 의미가 달라지는 원시 바이트가 아니라 é로 작성됩니다. 이 규칙의 거울 이미지는 들어오는 과정에도 적용됩니다. XLSX에서 다시 읽어들인 통합 문서는 문자가 숫자 XML 엔티티로 이미 저장되어 있을 수 있는 공유 문자열을 포함하며, 이 엔티티는 셀 모델에 들어가기 전에 하나의 온전한 문자로 디코딩되어야 합니다. 조심하지 않고 디코딩하여 코드 포인트를 별도의 바이트로 분할하면, 단일 문자가 이후의 어떤 내보내기도 복구할 수 없는 두 조각의 글씨 깨짐(mojibake)으로 다시 나타납니다
XLSX 컨테이너는 ZIP이며 ZIP은 자체 이름 인코딩을 가집니다
XLSX 파일은 ZIP 아카이브이며 아카이브는 저장된 모든 멤버의 이름을 보관합니다. ZIP은 원래 사양에서 이름의 인코딩에 대해 아무런 언급이 없을 만큼 오래되었으므로 어떤 신호도 찾지 못한 리더는 아카이브의 로컬 코드 페이지를 가정합니다. 이 가정은 멤버 이름에 비-ASCII 문자가 포함되는 순간 틀리게 됩니다. 이 상황은 현지화된 워크시트 파트 이름과 파일 이름에 악센트나 비-라틴 문자가 포함된 포함 미디어에서 발생합니다
해결책은 단일 비트입니다. 각 로컬 파일 헤더의 범용 비트 11은 멤버 이름이 UTF-8로 인코딩되었음을 선언합니다. HotXLS는 아카이브를 읽을 때 정확히 이 비트를 확인하여 $0800 마스크와 범용 플래그를 대조 테스트합니다. 이를 무시하는 리더나 작성기는 올바른 구현이 UTF-8로 저장한 이름을 잘못 읽을 것입니다. 이 비트를 설정하고 따르는 데 드는 비용은 저렴하며, 왕복 과정에서 멤버 이름이 살아남는지 아니면 스프레드시트 콘텐츠가 파싱되기도 전에 깨져서 도착하는지를 가르는 전체 차이입니다
대소문자 접기(case folding)와 숫자 스캐닝은 동일한 위험을 숨기고 있습니다
수식 평가는 유니코드 안전이 직렬화의 문제가 아니라 비교의 문제가 되는 곳입니다. SEARCH 함수는 대소문자를 구분하지 않으므로 부분 문자열을 찾기 전에 대소문자 접기를 해야 합니다. 잘못된 접기 방식은 ANSI 코드 페이지를 통한 것입니다. 그 방식으로 비-ASCII 텍스트를 대문자로 변환하면 문자가 좁은 코드 페이지를 거치면서 이를 벗어나는 모든 것이 훼손되기 때문입니다. 올바른 방법은 와이드 문자열(wide-string) 대문자 변환이며, 이는 전체 UTF-16 범위를 보존합니다. HotXLS는 정확히 이 이유 때문에 WideUpperCase로 접으므로, 악센트가 있거나 비-라틴 텍스트에 대한 검색은 코드 페이지로 훼손된 근사치가 아니라 입력된 것과 정확히 같은 문자와 일치하게 됩니다
수식 토크나이저(tokenizer)는 문자와는 아무 상관이 없고 토큰이 어디서 끝나는지와 전적으로 관련된 연관된 의무를 가지고 있습니다. 1E3이나 2.5E-3과 같은 과학적 기수법은 단일 숫자 리터럴이며, 스캐너는 E, 선택적 부호, 그리고 뒤따르는 자릿수를 별도의 숫자가 뒤따르는 이름으로 나누지 않고 숫자의 일부로 인식해야 합니다. 이를 잘못 다루는 스캐너는 완벽하게 유효한 상수를 파싱 오류로 만들거나, 더 심각하게는 아무 경고 없이 잘못된 표현식으로 만들어 버립니다. 이는 동일한 논의에 속합니다. 두 경우 모두 리더가 올바른 문자 수준의 결정을 내리는 것에 관한 것이기 때문입니다. 하나는 비교를 위해 문자를 접는 방법에 관한 것이고, 다른 하나는 문자가 현재 토큰을 이어가는지에 대한 것입니다
다국어 통합 문서 작성 및 내보내기
퍼블릭 API는 여러분에게 이 중 어느 것도 생각하도록 요구하지 않습니다. 여러분은 WideString 셀 값에서 통합 문서를 작성하고 원하는 내보내기 진입점을 호출하면 됩니다. 인코딩 결정은 각 작성기(writer) 내부에서 발생합니다. 아래의 예제는 여러 문자로 구성된 텍스트로 시트를 채운 다음, 동일한 통합 문서에서 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은 마크업에서 중요한 문자에 대한 엔티티 이스케이프, 문자 셋이 보장되지 않는 곳에 대한 숫자 참조, 또한 공유 문자열로 도착하는 엔티티의 올바른 디코딩이 필요합니다. ZIP 컨테이너는 UTF-8 멤버 이름이 UTF-8로 읽히도록 범용 비트 11이 설정되어야 합니다. 수식 평가에는 와이드 문자열 대소문자 접기와 과학적 표기법을 한 조각으로 유지하는 토크나이저가 필요합니다. 각각은 다른 계약이며, 라이브러리는 하나를 충족시키면서 조용히 다른 것을 위반할 수 있습니다. 이것이 CSV를 올바르게 처리하는 도구가 여전히 물음표로 가득 찬 RTF를 넘겨줄 수 있는 이유입니다
내보내기가 구분된(delimited) 형식에 의존한다면 형식을 간의 트레이드오프는 CSV, TSV 및 HTML 내보내기 가이드에서 다루고 있으며, 출처가 수동으로 구축된 시트가 아니라 결과 셋일 때 Delphi 보고서를 위한 데이터베이스 내보내기의 패턴이 여기에 설명된 인코딩 규칙과 자연스럽게 결합됩니다. 이 모든 것은 Delphi와 C++Builder용 HotXLS 컴포넌트의 일부로 제공되며, 본 블로그의 다른 곳에서 다루는 읽기, 수식 및 서식 지정 API와 함께 제공됩니다