v3.539.47 이전의 PDF Library for Delphi(PDFlibPas)는 HTML이나 Markdown을 PDF에 그릴 때 이스케이프된 텍스트를 두 번 디코딩할 수 있었습니다. DrawHTMLText와 DrawHTMLTextBox는 HTML을 파싱한 뒤 다시 HTML로 정규화하고 또 파싱하므로, <unsafe>로 쓰인 텍스트가 두 번째 파싱에 진짜 태그로 도달했습니다. v3.539.47부터는 각 entity가 정확히 한 번만 디코딩되고, 텍스트가 다시 HTML로 돌아가는 모든 지점에서 재이스케이프됩니다
이 버그를 드러내는 시나리오는 평범합니다. 헬프데스크가 티켓을 PDF로 내보내고 고객 코멘트가 HTML 템플릿에 들어갑니다. 개발자는 바르게 코멘트를 이스케이프해서 <b>가 <b>가 됐습니다. 그런데 렌더러 안에서 그 이스케이프가 조용히 풀렸습니다. 코멘트는 굵은 글씨로 나오고, 모르는 태그 이름은 페이지에서 그냥 사라지고, 이스케이프된 앵커는 클릭할 수 있는 링크 어노테이션으로 변합니다. 예외도 경고도 없이, 데이터와 다른 내용을 말하는 완벽하게 유효한 PDF가 나옵니다
이스케이프된 텍스트는 PDF에서 어떻게 진짜 태그가 될까?
이스케이프된 텍스트가 마크업이 된 이유는 렌더러가 파싱을 두 번 돌리고, 그 사이의 정규화 단계가 이미 디코딩된 텍스트를 재이스케이프 없이 HTML에 다시 썼기 때문입니다. 첫 파싱이 수행한 모든 디코딩이 두 번째 파싱에 살아 있는 문법으로 넘어갔습니다
두 패스가 존재하는 데는 이유가 있습니다. 첫 파싱은 태그와 워드 엘리먼트 목록을 만들고, NormalizeParsedHTML이 이어서 스타일시트 cascade를 해석합니다. <style> 블록의 규칙을 각 태그와 맞춰 보고 inline style 속성과 병합한 뒤 결과를 태그에 저장하고, 엘리먼트 목록 전체를 HTML 문자열로 직렬화하죠. 레이아웃 패스가 그 정규화된 문자열을 파싱합니다. PDFlibPas HTML 렌더링의 flexbox, CSS grid, 각주 레이아웃을 움직이는 바로 그 기계 장치입니다
결함은 워드를 직렬화하는 방식에 있었습니다. 태그는 원본 소스 형태 그대로 써 돌려진 반면, 워드는 디코딩된 형태로 써 돌려졌습니다. 첫 파싱이 <unsafe>에서 <unsafe>로 디코딩한 워드는 정규화된 HTML에 날것 각괄호로 착지했고, 두 번째 파싱은 그것을 엘리먼트로 읽었습니다. 그 핵심 버그 주변에는 같은 방향을 가리키는 작은 누수 셋이 붙어 있었습니다:
&는 지원 entity 집합에 없었으므로R&D는 문자 그대로 찍혔고,<같은 entity 표기를 텍스트로 쓸 방법도 없었습니다- 드로잉 단계는 파싱이 이미 끝난 뒤
를 두 번째로 치환했으므로, literal entity 표기가 마지막에 사라질 수 있었습니다 - Markdown 코드 이스케이프는 앰퍼샌드를 건너뛰었고 데이터셋 익스포터는 각괄호만 이스케이프했으므로, 코드나 셀 값 안의 entity 표기가 마크업으로 디코딩됐습니다
| 렌더러에 도달하는 입력 | v3.539.47 이전 | v3.539.47 이후 |
|---|---|---|
<unsafe> | 태그로 파싱됨, 텍스트는 페이지에 도달하지 않음 | <unsafe>가 텍스트로 그려짐 |
<b>x</b> | x가 굵게 그려짐 | <b>x</b>가 텍스트로 그려짐 |
R&D | R&D가 문자 그대로 찍힘 | R&D |
&lt; | &lt;가 문자 그대로 찍힘 | < |
를 담은 Markdown 코드 스팬 | non-breaking space로 바뀜 | 가 텍스트로 그려짐 |
데이터셋 셀 값 < | < | < |
v3.539.47은 HTML entity 디코딩을 어떻게 한 패스로 만드나
PDFlibPas v3.539.47은 세 가지 맞춤 변경으로 entity 디코딩을 한 패스로 만듭니다. 파서가 &를 마지막에 디코딩하고, 드로잉 단계는 더 이상 아무것도 디코딩하지 않으며, 디코딩된 워드를 다시 HTML로 바꾸는 모든 지점이 먼저 재이스케이프합니다
텍스트 콘텐츠의 지원 entity 집합은 이제 <, >, &, 입니다. A 같은 숫자 참조와 " 같은 이름 entity를 포함해 나머지는 전부 literal 텍스트로 남습니다. 이 경계는 아래에서 보듯 직접 입력을 이스케이프하는 방식에도 중요합니다
디코더 안의 순서가 첫 수정입니다. &를 먼저 디코딩했다면 입력 &lt;는 <가 되고 다음 치환이 그것을 <로 바꿉니다. 한 패스 안에서 일어나는 이중 디코딩이죠. 그래서 ANSI 워드 경로는 <, >, 를 먼저 치환하고 &를 마지막에 치환하므로, 만들어진 앰퍼샌드는 다시 검사되지 않습니다. UTF-16 워드 경로는 두 바이트 단위로 왼쪽에서 오른쪽으로 한 번 훑으며 각 매치를 제자리에 다시 쓰고 그다음으로 넘어가므로, 구조적으로 같은 보장을 줍니다
두 번째 수정은 늦은 치환을 드로잉 단계에서 뺀 것입니다. 디코딩은 파서의 소관이고 그 외에는 없으므로, 행 분할기에 도달하는 워드는 최종 텍스트입니다
세 번째 수정은 경계 규칙입니다. NormalizeParsedHTML은 이제 디코딩된 모든 워드의 &, <, >를 정규화된 HTML에 붙이기 전에 이스케이프합니다. 두 번째 파싱이 그것을 정확히 같은 텍스트로 되돌려 디코딩하므로, 파이프라인 전체의 순효과는 디코딩 한 번입니다. 이어지는 문자열도 같은 규칙을 따릅니다. 상자에 들어가지 못한 워드는 LeftOverText에 붙기 전에 이스케이프되고, 나머지 부분은 이미 이스케이프된 형태인 정규화된 HTML에서 복사됩니다. 그 남은 워드들을 모으는 루프도 이제 워드 개수로 경계가 지어졌는데, 옛 repeat 루프는 마지막 워드를 지나쳐 밟을 수 있었습니다
UTF-16BE 이스케이프는 바이트 수준 치환을 쓰면 안 되는 이유는?
UTF-16BE 이스케이프는 바이트 수준 치환을 쓰면 안 됩니다. 앰퍼샌드의 두 바이트 패턴이 서로 무관한 두 문자에 걸쳐 놓일 수 있기 때문입니다. 올바른 작업 단위는 16비트 code unit 전체뿐입니다
렌더러는 Unicode 워드를 high byte 먼저인 big-endian UTF-16으로 바이트 문자열에 담아 저장합니다. 앰퍼샌드는 00 26이죠. 이번엔 U+0100(macron 달린 라틴 대문자 A, 바이트 01 00) 다음에 U+2603(눈사람, 바이트 26 03)이 오는 경우를 봅시다. 바이트 열은 01 00 26 03이고 두세 번째 바이트는 00 26으로 읽힙니다. #0'&'를 찾는 바이트 검색은 존재하지 않는 앰퍼샌드를 찾아내고, &의 바이트를 두 문자 한가운데에 끼워 넣으며, 뒤따르는 모든 문자를 한 바이트씩 밀어 버립니다
별난 코너 케이스가 아닙니다. low byte가 0인 문자는 누구든 앞 절반을 공급할 수 있고, 가장 자주 쓰이는 CJK 한자 중 하나인 U+4E00이 조건에 해당합니다. 각괄호도 같은 노출에 있습니다. 그런 문자 다음에 CJK Extension A의 U+3C00부터 U+3EFF 사이 문자가 오면 00 3C와 00 3E가 나타납니다. EscapeHTMLWord의 수정은 바이트를 WideString으로 풀고 문자별로 이스케이프한 뒤 결과를 다시 패킹합니다. 디코더 쪽은 짝수 code unit 경계에서만 패턴을 검사했으므로 이미 안전했습니다
같은 규칙이 직접 쓰는 코드에도 적용됩니다. TEncoding.BigEndianUnicode.GetBytes 이후처럼 UTF-16 텍스트를 TBytes로 들고 있다면 바이트 패턴을 검색하지 마세요. 문자열로 되돌려 문자 단위로 작업하세요
Markdown 코드 블록과 데이터셋 익스포트: 앰퍼샌드를 먼저 이스케이프한다
v3.539.47부터 PDFlibPas 안의 두 HTML 생산자, 즉 Markdown 변환기와 데이터셋 익스포터는 각괄호보다 앰퍼샌드를 먼저 이스케이프하므로, 렌더러의 한 번 디코딩이 원본 텍스트를 정확히 복원합니다
MarkdownToHTML에서 inline 코드 스팬과 fenced·들여쓰기 코드 블록은 이제 &를 &로, <를 <로, >를 >로 바꾸고, 공백은 가 되며 탭은 들여쓰기를 지키려고 네 개로 늘어납니다. 평범한 Markdown 산문은 각괄호만 이스케이프하므로 산문의 raw HTML은 태그를 주입할 수 없고, 동시에 저자는 &를 의도적으로 쓸 수 있습니다. Markdown 저자들이 기대하는 대로죠. DrawMarkdownText와 DrawMarkdownTextBox도 같은 변환을 쓰므로 코드는 입력 그대로 PDF에 나타납니다:
uses
System.SysUtils, PDFlibrary;
procedure RenderCodeSample;
var
Lib: TPDFlib;
Md, Html: WideString;
begin
Md := 'Comparison helper:' + sLineBreak + sLineBreak +
'```' + sLineBreak +
'if (A < B) and (Flags <> 0) then' + sLineBreak +
' WriteLn(''<tag> & R&D'');' + sLineBreak +
'```';
Lib := TPDFlib.Create;
try
// HTML 검사: 코드 안에서 '&'는 '&'로, '<'는 '<'로 바뀝니다
Html := Lib.MarkdownToHTML(Md);
Lib.SetOrigin(1); // 왼쪽 위 원점, Y는 아래로 증가
Lib.SetMeasurementUnits(0); // 포인트 단위
// 페이지에는 입력 그대로 표시, entity 표기 포함
Lib.DrawMarkdownText(50, 50, 495, Md);
Lib.SaveToFile('code-sample.pdf');
finally
Lib.Free;
end;
end;
데이터셋 익스포터는 가르침이 되는 케이스입니다. v3.539.47 전에는 각괄호만 이스케이프했는데, 일부러 그랬습니다. 렌더러가 &를 디코딩하지 않았으니 앰퍼샌드를 이스케이프하면 그것을 담은 모든 셀에 &가 찍혔을 테니까요. 그 편법은 옛 렌더러에는 맞았지만 일반적으로는 틀렸습니다. 우연히 <를 담은 셀 값이 <로 디코딩됐으니까요. 렌더러가 고쳐진 지금은 익스포터가 &를 먼저 이스케이프하고, R&D < & 같은 값이 PDF에 그대로 착지합니다. 이런 식으로 리포트를 만든다면 Delphi에서 TDataSet을 PDF 리포트로 내보내기 트레킹이 익스포터의 나머지를 다룹니다
앰퍼샌드가 먼저 가야 하는 이유는 한 번쯤 짚고 넘어갈 가치가 있습니다. <를 먼저 이스케이프하면 <가 되고, 이어서 &를 이스케이프하면 &lt;가 되는데, 올바른 한 번 디코딩은 그것을 <가 아니라 <로 표시합니다. 순차 치환 체인은 이스케이프 문자 자신이 그것을 도입하는 어떤 것보다 먼저 다뤄질 때만 옳습니다
DrawHTMLTextBox용 신뢰할 수 없는 텍스트는 어떻게 이스케이프해야 할까?
PDFlibPas HTML 렌더링에서는 신뢰할 수 없는 텍스트 콘텐츠를 &, 그다음 <, 그다음 > 순서로 정확히 한 번 치환해 이스케이프하고, 신뢰할 수 없는 데이터는 속성 값에 아예 넣지 마세요
uses
System.SysUtils, PDFlibrary;
// PDFlibPas HTML 텍스트 콘텐츠용 신뢰할 수 없는 텍스트 이스케이프
// '&'를 먼저 바꿔야 합니다. 그러지 않으면 이미 만들어진
// '<' 안의 앰퍼샌드가 두 번 이스케이프됩니다
function EscapeHTMLText(const S: string): string;
begin
Result := StringReplace(S, '&', '&', [rfReplaceAll]);
Result := StringReplace(Result, '<', '<', [rfReplaceAll]);
Result := StringReplace(Result, '>', '>', [rfReplaceAll]);
end;
procedure RenderTicket(const CustomerComment: string);
var
Lib: TPDFlib;
Html: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.SetOrigin(1);
Lib.SetMeasurementUnits(0);
Html := '<p><b>Customer comment</b></p>' +
'<p>' + EscapeHTMLText(CustomerComment) + '</p>';
Lib.DrawHTMLText(50, 50, 495, Html);
Lib.SaveToFile('ticket.pdf');
finally
Lib.Free;
end;
end;
v3.539.47에서는 Try <a href="https://example.com">this</a> & <b> 같은 코멘트가 페이지에 문자 그대로 나타납니다. v3.539.47 전에는 같은 이스케이프 입력이 살아 있는 링크 어노테이션을 만들 수 있었고, 디스플레이 결함이 보안 문제로 변하는 지점이 바로 여기입니다. 티켓 코멘트가 직원들이 신뢰하는 문서에 클릭할 수 있는 URL을 심을 수 있어서는 안 됩니다
이 함수가 이스케이프하지 않는 것도 눈여겨보세요. 범용 HTML 이스케이퍼는 "를 "로, '를 '로도 바꾸는데, 브라우저에는 맞는 동작입니다. PDFlibPas 텍스트 디코딩은 앞서 나열한 네 entity만 인식하므로 그 둘은 "와 '로 문자 그대로 찍힙니다. 따옴표는 텍스트 콘텐츠에서는 무해하고, 속성 값 안에서만 문제가 되는데, 렌더러는 속성의 entity를 전혀 디코딩하지 않습니다. 그러니 안전한 설계는 더 나은 이스케이퍼가 아니라 규칙입니다. 신뢰할 수 없는 데이터는 href, src, style에 절대 들어가지 않는다. 링크 타깃이 정말 사용자 데이터에서 와야 한다면 스킴과 문자 allow-list로 직접 검증하고 따옴표나 각괄호를 담은 것은 거부하세요
이 수정에서 바로 따라 나오는 업그레이드 노트 두 가지:
- 옛 버전이
&를 문자 그대로 찍어서 코드가&이스케이프를 뺐다면 다시 넣으세요. 없으면<를 담은 사용자 텍스트가 이제<로 표시됩니다. 여전히 무해한 텍스트지만 사용자가 입력한 것과는 다르죠 - 두 번 이스케이프하지 마세요. 이스케이퍼 두 개를 통과한 텍스트는
<를 눈에 보이는 표기<로 렌더링하므로, 데이터가 HTML에 들어가는 그 한 경계를 찾아 거기서만 이스케이프하세요
이스케이프를 깨지 않고 LeftOverText로 페이지 나누기
DrawHTMLTextBox는 들어가지 못한 HTML을 반환하는데, 보통 LeftOverText라 부릅니다. v3.539.47부터 그 나머지는 literal entity 표기와 이스케이프된 각괄호를 보존하니 다음 상자에 그대로 넘기면 됩니다. 호출자의 규칙은 단순합니다. 그대로 되돌려 준다
const
BoxLeft = 50;
BoxTop = 50;
BoxWidth = 495; // A4 페이지 기준 포인트 크기
BoxHeight = 740;
MaxPages = 500;
procedure RenderLongHTML(Lib: TPDFlib; const Html: WideString);
var
Rest: WideString;
Pages: Integer;
begin
Lib.SetOrigin(1);
Lib.SetMeasurementUnits(0);
Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Html);
Pages := 1;
while (Rest <> '') and (Pages < MaxPages) do
begin
Lib.NewPage;
Inc(Pages);
// LeftOverText는 이미 이스케이프된 엔진 HTML: 절대 이스케이프하거나 풀지 말 것
Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
end;
if Rest <> '' then
raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;
나머지는 불투명한 것으로 취급하세요. 스타일이 이미 해석된 엔진의 정규화된 HTML이므로, 자신의 이스케이퍼에 통과시키지도, 디코딩하지도, 사용자 텍스트를 끼워 넣지도 마세요. 페이지 상한값은 값싼 보험입니다. 어떤 엘리먼트가 상자에 영영 들어갈 수 없다면, 상한 없는 루프에는 자연스러운 출구가 없습니다
Markdown에는 자기만의 이어짐이 있습니다. DrawMarkdownTextBox는 내부 마커로 시작하는 토큰을 반환해 다음 호출이 변환을 건너뛰게 합니다. 그 토큰은 DrawMarkdownTextBox나 DrawMarkdownText로 돌려 보내세요. HTML 진입점에 넘기면 마커가 텍스트로 그려집니다
일반 교훈: 한 번 디코딩하고, 모든 경계에서 다시 인코딩한다
텍스트를 파싱하고 결과를 같은 문법으로 다시 직렬화한 뒤 또 파싱하는 파이프라인이라면, 디코딩을 정확히 한 곳에서만 일어나는 연산으로 취급하고 디코딩된 텍스트가 다시 문법이 되는 모든 경계에서 재인코딩해야 합니다. 템플릿 엔진, HTML 새니타이저, Markdown-HTML-PDF 체인이 이 모양을 공유하고, 직렬화기가 자신이 마크업을 만든다는 사실을 잊으면 같은 식으로 무너집니다
모양을 알면 증상은 예측 가능합니다. 재인코딩이 부족하면 데이터가 문법으로 변합니다. 주입 방향이죠. 인코딩이 과하거나 디코더가 두 번 돌면 entity 표기가 독자에게 보이거나 삼켜집니다. 표시 방향이죠. 한 방향만 고치면 보통 다른 쪽이 깨지므로, PDFlibPas 수정은 같은 릴리스에서 & 디코딩을 추가하고 순서를 바꾸고 늦은 디코딩을 빼고 재이스케이프를 더해야 했습니다. 같은 원리는 PDF 콘텐츠를 구조화 텍스트로 내보낼 때 반대 방향으로 흐릅니다. Delphi에서 PDF를 Markdown과 DOCX로 시맨틱 익스포트가 그 사례인데, 거기서는 literal 문자마다 타깃 문법용 이스케이프를 정확히 한 번씩 해야 합니다
빠른 참조 체크리스트
- 사용자 데이터를 담은 HTML이나 Markdown을 렌더링한다면 PDFlibPas v3.539.47 이상으로 업그레이드
- 텍스트 콘텐츠는
&먼저, 그다음<와>로 이스케이프. PDFlibPas 텍스트에는 따옴표를 바꾸지 않음 - 데이터가 HTML 문자열에 들어가는 단일 지점에서 한 번만 이스케이프
- 신뢰할 수 없는 값은
href,src,style에 넣지 말고, 넣어야 한다면 allow-list로 검증 - 텍스트에서는
<,>,&, 만 디코딩된다고 기대. 다른 entity는 literal로 유지 LeftOverText는 그대로DrawHTMLTextBox에 되돌리고 페이지 루프에는 상한을 둠- Markdown 이어짐 토큰은
DrawMarkdownTextBox나DrawMarkdownText에만 넘김 - UTF-16 바이트 버퍼에서 바이트 패턴을 검색하지 말고 code unit 전체로 작업
HTML과 Markdown 렌더링, 데이터셋 리포트 익스포트, 레이아웃 엔진의 나머지는 Delphi와 Free Pascal용인 PDF Library for Delphi의 네이티브 Pascal 소스에 실려 나갑니다. 에디션, 플랫폼 지원, 평가판 다운로드는 PDFlibPas 제품 페이지를 보세요