PDF 파일에 들어가는 모든 실수는 운영체제가 소수 구분자를 무엇으로 여기든 반드시 마침표로 써야 합니다. HotPDF Delphi Component는 그 모두를 HPDFTypes의 단일 로케일 불변 변환 계층으로 흘려보내므로, 독일, 프랑스, 브라질 워크스테이션에서도 0.5 0 0 0.5 5.5 7.25 cm가 출력되며 어떤 규격 준수 리더도 파싱하지 못할 쉼표 버전은 나오지 않습니다
두 줄이면 고칠 수 있는 버그처럼 들립니다. 실제로는 아니었습니다. 이 결함은 수년간 부수 효과에 가려져 있었고, 누구도 숫자 포맷팅 코드라고 생각하지 않는 세 서브시스템에서 다시 나타났으며, 한 컴파일러에서는 사라지고 다른 컴파일러에서는 살아남았고, 마지막 층은 소수 구분자와 전혀 무관했습니다
소수점 쉼표 로케일은 왜 PDF 콘텐츠 스트림을 망칠까요?
ISO 32000-1 §7.3.3이 숫자 객체를 선택적 부호, 십진 숫자, 기껏해야 마침표 하나로 정의하기 때문입니다. 쉼표는 그 생성 규칙의 일부가 아니고 §7.2.2에서도 구분자가 아니므로, 리더는 여러분이 의도한 피연산자 두 개를 보지 못합니다. 렉싱에 실패하는 토큰을 볼 뿐이며, 그 토큰을 담은 연산자까지 통째로 버려집니다. 그래픽스 상태 행렬의 0,5 하나가 주변의 q ... Q 블록 전체를 무너뜨립니다. 이 실패는 현장에서도 독특한 모습을 보입니다. 개발자 머신에서는 절대 재현되지 않고, 어느 한 국가에서 온 서포트 티켓으로 도착하며, 대부분의 뷰어가 망가진 연산자를 조용히 버리고 나머지 페이지를 렌더링하기 때문에 구문 오류가 아니라 렌더링 버그로 읽힙니다
// DecimalSeparator = ',' 로 실행 중인 호스트 프로세스
Doc.LoadFromFile('invoice.pdf');
Doc.RedactLoadedRect(0, 10.25, 20.5, 40.75, 60.5, 0.5, 0.25, 0.125);
Doc.StitchLoadedPage(0, 1, 5.5, 7.25, 0.5);
// 추가된 /Contents가 운반하던 내용:
// 0,5 0,25 0,125 rg 10,25 20,5 30,5 40 re f
// q 0,5 0 0 0,5 5,5 7,25 cm /StitchSrc Do Q
//
// ISO 32000-1 7.3.3에는 "0,5" 생성 규칙이 없어 수정
// 사각형과 이어붙인 페이지가 모두 오류 없이 사라집니다
결함을 수년간 숨긴 부수 효과
HotPDF는 생성 경로의 대부분 숫자를 _CutFloat로 쓰는데, 그 함수의 초기 버전은 조용히 적대적인 일을 했습니다. 호출할 때마다 호스트 프로세스의 전역 FormatSettings.DecimalSeparator에 '.'을 할당한 것입니다. 이는 컴포넌트를 호스팅하는 애플리케이션의 로케일 상태를 망가뜨리고 스레드 안전하지도 않지만, 흥미로운 결과는 셋째입니다. 콘텐츠가 한 번 생성되고 나면 프로세스의 구분자는 마침표가 되므로, 라이브러리 곳곳의 알몸 FloatToStr와 StrToFloat가 우연히 전부 올바르게 됩니다. 그래서 거의 모든 샘플이 쓰는 생성-후-편집 순서는 통과했고, 그 순서가 가린 것은 첫 _CutFloat 호출 전에 실행되는 모든 경로였습니다. 로드된 문서 편집기 StitchLoadedPage와 RedactLoadedRect, 텍스트 및 이미지 스탬프, 복합 워터마크, Format('%.2f')를 쓰는 바코드 그리기, /DA 기본 모양 파서, Type 1 폰트 서브시스템 전체가 그렇습니다
Type 1 파싱은 그중에서도 가장 지저분하게 실패했습니다. FontMatrix, BlueValues, stem 힌트, Type 2 charstring 안의 실수는 Adobe Type 1 Font Format 명세에 따라 마침표로 쓰인 PostScript 실수입니다. 쉼표 로케일에서 0.001을 파싱하면 EConvertError가 발생하고, 감싼 try/except가 이를 삼켜 폰트 행렬은 0에 머물렀습니다. 그 폰트의 모든 글리프가 무너져 내렸는데, 이는 로케일 문제라기보다 폰트 문제처럼 보여서 여러분을 임베디드 글리프 렌더링 디버깅에 한나절을 보내게 만듭니다
흩어진 패치 대신 단일 변환 계층
HotPDF가 지금 따르는 규칙은 단순합니다. PDF, PostScript, JSON, JavaScript 문법으로 쓰이거나 그 문법에서 읽히는 모든 숫자는 명시적인 로케일 불변 변환을 거치며, 어떤 코드도 전역 FormatSettings 부수 효과에 의존하지 않습니다. 네 가지 변환과, 그것들이 공유하는 설정 record, 바코드 경로용 고정 소수점 헬퍼 하나는 모두 HPDFTypes가 소유합니다
// Lib/HPDFTypes.pas - 단일 로케일 불변 변환 표면
function HPDFInvariantFormatSettings: TFormatSettings;
function HPDFFloatToInvariantStr(Value: Extended): string;
function HPDFTryInvariantStrToFloat(const S: string;
out Value: Extended): Boolean;
function HPDFInvariantStrToFloat(const S: string): Extended;
function HPDFInvariantStrToFloatDef(const S: string;
const Default: Extended): Extended;
function HPDFFormatInvariant2(Value: Extended): string;
두 유닛 HPDFContentStream과 HPDFTextExtraction은 이미 저마다 private InvariantFormatSettings 함수를 지니고 있었고, 병렬 구현으로 남기지 않고 한 줄짜리 forward로 줄였습니다. Type 1 서브시스템은 의도된 예외입니다. VCL 의존성이 없고 HPDFTypes를 참조하지 않으므로, 그래픽 계층을 폰트 파서에 끌어들이는 것보다 열 줄짜리 함수를 하나 복제하는 게 더 작은 부채라는 판단 아래 동일한 의미의 로컬 미러 HPDFType1Core.Type1StrToFloat를 유지합니다. 일부러 건드리지 않은 곳도 두 군데 있습니다. HPDFHTMLImport는 CSS 길이를 파싱할 때 일부러 마침표를 로케일 구분자로 바꾼 뒤 로케일로 파싱하는데, 그 입력에는 그게 맞습니다. HPDFType1CB.GlyphCommands와 FontBBox 문자열은 호출자가 자기 StrToFloat로 다시 읽는 공개 API라서, 같은 로케일에서 생산하고 소비하는 것이 자기 일관적이며 불변 출력은 쉼표 지역의 모든 호출자를 깨뜨릴 것입니다
HPDF_HAS_FORMAT_SETTINGS는 컴파일러마다 무엇을 보장할까요?
지원하는 모든 툴체인에서 불변 경로가 실제로 컴파일된다는 것을 보장합니다. 당연해 보이는 버전 게이트는 그걸 보장하지 못했습니다. Delphi XE2가 TFormatSettings.Create와 TFormatSettings를 받는 FloatToStr / TryStrToFloat 오버로드를 도입했으므로, 첫 반응은 호출 지점마다 {$IFDEF XE2+}, InvariantFormatSettings{$ENDIF}라고 쓰는 것입니다. 그 반응에는 사각지대가 있습니다. HotPDF.inc는 Free Pascal 아래에서 D2009+를 정의하지만 XE2+는 절대 정의하지 않습니다. XE2+는 Delphi 버전 번호에 대한 질문에 답하는 것이고, Free Pascal 3.2.2는 그 오버로드를 조건 없이 제공하기 때문입니다. 그래서 그 게이트들은 전부 FPC 빌드에서 조용히 로케일 의존 분기로 퇴화했고, 정확히 최초 리포트가 나온 플랫폼에서 그랬습니다. HPDF_HAS_FORMAT_SETTINGS는 HotPDF.inc의 FPC 블록과 XE2+ 블록 양쪽에 정의되며, 숫자 헬퍼가 시험하는 유일한 심볼입니다
{$IFDEF HPDF_HAS_FORMAT_SETTINGS}
function HPDFInvariantFormatSettings: TFormatSettings;
begin
{$IFDEF FPC}
Result := DefaultFormatSettings; // FPC에는 TFormatSettings.Create가 없음
{$ELSE}
Result := TFormatSettings.Create;
{$ENDIF}
Result.DecimalSeparator := '.';
Result.ThousandSeparator := ',';
end;
{$ENDIF}
함수 안에는 컴파일러 분기 하나가 남습니다. Free Pascal에는 TFormatSettings.Create가 없어서 DefaultFormatSettings에서 시작해야 하기 때문입니다. 변환에 들어가는 것은 두 숫자 구분자뿐이라, 날짜와 통화 필드는 현재 로케일이 공급하는 대로 유지됩니다. XE2 이전 Delphi에는 오버로드가 아예 없으며, 그곳의 헬퍼는 프로세스 로케일로 변환한 뒤 결과 문자열의 구분자 문자를 다시 쓰는 방식으로 폴백합니다. 지저분하지만 전역 상태에는 절대 손대지 않습니다
세 번째 층: SVG는 허용하고 PDF는 허용하지 않는 지수
소수 구분자를 고치는 것은 필요하지만 충분하지 않습니다. W3C SVG 1.1 §4.2는 숫자 생성 규칙에서 지수를 허용하고 XPS 마크업도 마찬가지이므로, 1E-6은 들어올 때 완벽히 유효한 좌표인 반면 ISO 32000-1 §7.3.3은 지수를 전혀 허용하지 않습니다. 불변 포맷팅을 살아남은 값도 1E-6 형태로 콘텐츠 스트림에 들어가면 거부될 수 있고, 고정 형식의 FloatToStrF조차 값이 RTL 숫자 범위를 벗어나면 지수 형태로 폴백할 수 있습니다. 그래서 HPDFSafeSVG.SVGFloat은 불변 변환 이후에 자기 할 일을 따로 합니다. 1.0E-7 미만의 것은 0으로 밀어버리고, E가 살아남으면 지수를 떼어내 소수점을 손수 옮기며 시프트가 요구하는 쪽에 0을 채웁니다. SVGFinite는 NaN과 ±1.0E30 밖의 값을 포매터에 닿기 전에 거부해 입구를 지키고, SVGReadNumber와 SVGSkipSeparators는 RTL에 미루지 않고 인바운드 방향을 문자 단위로 처리합니다
// Lib/HPDFSafeSVG.pas - PDF 피연산자용 십진 표기
if Abs(Value) < 0.0000001 then
Value := 0;
S := HPDFFloatToInvariantStr(Value);
ExponentAt := Pos('E', UpperCase(S));
if ExponentAt > 0 then
begin
ExponentValue := StrToInt(Copy(S, ExponentAt + 1, MaxInt));
SetLength(S, ExponentAt - 1);
// ... ExponentValue만큼 소수점을 옮기고 '0'으로 왼쪽 또는
// 오른쪽을 채운 뒤 부호를 다시 붙입니다
end;
이 TSVGBuffer는 SVG 페이지 내보내기와 그 그래픽스 상태 처리에 먹이를 주는 바로 그 버퍼이며, HPDFCreateXPSDocumentHandler와 THPDFHandledDocumentFormat의 hdfXPS 멤버를 통해 XPS 및 OXPS 문서를 PDF로 변환할 때도 같은 숫자 규율이 가져오기 쪽에 적용됩니다. 마크업 형식은 숫자 문법에 관대합니다. PDF 콘텐츠 스트림은 그렇지 않으며, 그 차이는 변환 경계에서 해결되어야 합니다
로케일을 가정하지 않고 재현하는 회귀 테스트
남의 머신에서만 나타나는 로케일 버그는 테스트가 스스로 로케일을 설정하게 만들어 고정할 가치가 있으며, HotPDF는 그런 테스트를 두 개 지닙니다. LoadedEditsEmitInvariantNumbersUnderCommaLocale은 첫 편집 전에 FormatSettings.DecimalSeparator에 쉼표를 할당하고 RedactLoadedRect, StampLoadedPageText, StitchLoadedPage, 복합 스탬프를 실행한 뒤 페이지 콘텐츠 바이트를 다시 읽어, 정확한 연산자 문자열이 존재하고 버퍼 어디에도 쉼표가 없다는 것을 단언합니다. DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale은 나머지 절반과 원죄를 다룹니다. 같은 쉼표 로케일 아래에서 고정 소수점 2자리 포맷팅의 DirectDrawBarcode를 포함해 생성 경로를 구동한 뒤, 콘텐츠 스트림에 쉼표가 없고 EndDoc이 돌아올 때 FormatSettings.DecimalSeparator가 여전히 쉼표라는 것을 단언합니다
그 둘째 단언이 바로 원래 결함이 작성된 날 잡아냈을 단언입니다. 디스크에 쓰는 것뿐 아니라 라이브러리가 호스트에 행하는 일을 시험하기 때문입니다. 이 교훈은 PDF를 넘어 일반화됩니다. 고정 어휘 문법을 지닌 모든 형식, PostScript와 JSON과 JavaScript를 포함해서, 경계에 명시적인 불변 변환을 요구합니다. 전역 설정에 손을 대 정답을 얻는 방식은 누군가 여러분의 코드를 다른 순서로, 다른 스레드에서, 다른 컴파일러로 호출하기 전까지는 작동하며, 그쯤 되면 실패는 포맷팅 문제 외의 무엇으로도 보입니다
여기서 설명한 숫자 포맷팅 계층은 Delphi와 C++Builder용 HotPDF Delphi Component의 일부로 출하되며, 그에 의존하는 로드된 문서 편집, 바코드, SVG 내보내기, XPS 변환 경로도 함께 딸려 나옵니다