[10/10] PDF 렌더링 전쟁: SVG-to-PNG 변환, 이모지 폰트 라우팅, 그리고 iText NPE
요약
모듈형 평가 엔진 구축 과정에서 발생한 iText 라이브러리의 PDF 렌더링 오류와 해결 방법을 다룹니다. SVG 파싱 중 발생하는 NPE 문제를 해결하기 위해 SVG를 직접 렌더링하는 대신 PNG로 변환하여 처리하는 전략을 제시합니다.
핵심 포인트
- iText의 SVG 렌더러가 복잡한 SVG 경로 및 CSS 속성 처리 시 NPE를 유발함
- SVG를 PDF로 직접 변환하는 대신 PNG로 사전 변환하여 안정성 확보
- Batik 라이브러리를 독립 실행 모드로 사용하여 렌더링 신뢰도 향상
- PDF 생성 모드(`isForPdf`) 플래그를 통한 조건부 렌더링 경로 제어
시리즈: 모듈형 평가 엔진 구축 (10/10)
이 프로젝트의 최종 보스: PDF 렌더링입니다. 보고서 모듈은 HTML을 생성하며, iText가 이를 PDF로 변환합니다. 이론적으로 HTML → PDF 변환은 간단합니다. 하지만 실제로는 SVG 충돌, 이모지 가시성 문제, 폰트 라우팅 악몽이 뒤섞인 전쟁터입니다. 이 포스트에서는 세 가지 렌더링 전투와 각각의 승리 방법을 다룹니다.
전투 1: 모든 보고서를 충돌시킨 SVG NPE
버그 (The Bug)
차트(레이더 차트, 막대 차트)가 포함된 보고서는 PDF 생성 중에 다음과 같은 오류와 함께 충돌했습니다:
com.itextpdf.kernel.PdfException: Cannot convert PdfArray to float array.
at com.itextpdf.kernel.pdf.PdfArray.toFloatArray(...)
at com.itextpdf.svg.converter.SvgConverter.drawToPdf(...)
이 NPE(NullPointerException)는 iText의 SVG 렌더러(Batik 기반)가 SVG 경로(path) 데이터를 파싱하려고 할 때 발생했습니다. 충돌은 치명적이었습니다. PDF가 전혀 생성되지 않았고, 사용자는 "보고서 생성 실패" 메시지를 보게 되었습니다.
근본 원인 (The Root Cause)
차트 위젯은 <img 태그 내에 data:image/svg+xml;base64,... URL로 임베디드된 SVG를 생성했습니다. 이는 브라우저에서는 잘 작동합니다. 하지만 iText의 SVG 렌더러는 다음과 같은 알려진 문제들을 가지고 있습니다:
- 복잡한
<path>d속성 — Batik의PdfArray.toFloatArray()가 특정 경로 명령 시퀀스에서 실패합니다. - CSS-in-SVG 속성 — iText는 SVG 요소에 대한 CSS 속성을 완전히 지원하지 않습니다.
text-anchor가 포함된<text>— 부분적으로 지원되지만, PdfNumber 파싱 오류를 유발합니다.
네 가지 다른 위젯 유형이 영향을 받았습니다:
ChartWidget(일반 차트)FieldChartWidget(필드별 차트)ScaleChartWidget(차원 점수 차트)TrainExpertWidget(전문가 QR 코드 위젯)
해결책: SVG → PNG 변환
SVG를 iText의 취약한 렌더러에 직접 전달하는 대신, PDF 생성 전에 SVG를 PNG로 변환합니다:
// WidgetEntity.toHtml() 내부
if (context.isForPdf()) {
// PDF 모드: SVG를 PNG 데이터 URL로 변환
...
context.isForPdf() 플래그는 리포트 엔진이 PDF를 생성할 때 설정됩니다. SVG를 생성하는 모든 위젯(widget)은 이 플래그를 확인하고 PNG 변환기로 경로를 지정(route)합니다.
SvgToPngConverter
이 변환기는 Batik(iText가 내부적으로 사용하는 것과 동일한 라이브러리이지만, 독립 실행 모드로 사용)을 사용합니다:
public static String convertToPngDataUrl(String svgContent) {
// Batik으로 SVG 파싱
SAXSVGDocumentFactory factory = new SAXSVGDocumentFactory(...);
...
핵심 통찰: Batik 독립 실행 모드(PNG 변환용)는 iText에 내장된 Batik(직접적인 SVG 렌더링용)보다 더 안정적입니다. iText의 PdfArray 파서를 충돌시키는 동일한 SVG라도, 독립 실행형 PNG로는 문제없이 렌더링됩니다.
viewBox 차원 버그
하지만 PNG 변환기에도 자체적인 버그가 있었습니다. SVG는 viewBox(예: viewBox="0 0 280 64")를 통해 차원을 지정합니다. 기존 변환기는 viewBox를 무시하고 기본 전체 페이지 차원을 사용하고 있었습니다:
SVG viewBox: 280×64 (가로로 길고 세로로 짧음)
PNG 출력: 1024×768 (기본값, 잘못된 종횡비)
→ 차트가 거대한 투명 PNG 중앙에 아주 작은 띠 형태로 렌더링됨
...
해결 방법: viewBox에서 너비(width)와 높이(height)를 추출하여 PNG 차원에 사용합니다:
// viewBox="minX minY width height" 파싱
Pattern vbPattern = Pattern.compile("viewBox\s*=\s*['\"]([^'"]+)['\"]");
Matcher m = vbPattern.matcher(svgContent);
...
전투 2: PDF에서의 이모지 렌더링
버그
리포트 HTML에는 축하 메시지의 🎉, 차트 제목의 📊, 체크리스트의 ✅와 같은 이모지(emoji)가 포함되어 있습니다. 브라우저에서는 이들이 잘 렌더링됩니다. 하지만 PDF에서는 빈 사각형(tofu)으로 표시되거나 완전히 사라집니다.
근본 원인
iText는 텍스트를 렌더링하기 위해 폰트 등록(font registration)을 사용합니다. 기본 폰트(Helvetica, Times-Roman)에는 이모지 글리프(glyph)가 포함되어 있지 않습니다. iText가 이모지 코드포인트(codepoint)를 만나면 이를 지원하는 폰트를 찾지만, 찾지 못해 아무것도 렌더링하지 못하게 됩니다.
폰트 라우팅 문제
해결책은 간단해 보입니다. 이모지 폰트(Noto Color Emoji 또는 Apple Color Emoji 등)를 등록하는 것입니다. 하지만 다음과 같은 이유로 그렇게 간단하지 않습니다:
-
컬러 이모지 폰트 (Color emoji fonts) (Noto Color Emoji, Apple Color Emoji)는 비트맵/COLR 테이블을 사용합니다. iText의 폰트 엔진은 컬러 이모지를 완전히 지원하지 않으며, 이를 단색(monochrome)으로 렌더링하거나 아예 렌더링하지 못합니다.
-
단색 이모지 폰트 (Monochrome emoji fonts) (Symbola, DejaVu Sans)는 이모지 코드포인트를 포함하고 있지만, 이를 단순한 흑백 심볼로 렌더링합니다. 아무것도 없는 것보다는 낫지만, 훌륭한 결과는 아닙니다.
-
폰트 폴백 체인 (Font fallback chains) — iText는 각 유니코드 범위(Unicode range)에 대해 명시적인 폰트 등록이 필요합니다. 브라우저가 제공하는 것과 같은 자동 폴백 기능이 없습니다.
해결책: 폰트 커버리지 매트릭스 (Font Coverage Matrix)
서로 다른 유니코드 범위를 커버하는 여러 폰트를 등록합니다:
PdfFontFactory.register("/fonts/NotoSans-Regular.ttf", "noto-sans");
PdfFontFactory.register("/fonts/Symbola.ttf", "symbola-emoji");
// ... 기타 폰트
...
이 접근 방식은 완벽하지 않습니다. 이모지가 컬러가 아닌 흑백 심볼로 렌더링되기 때문입니다. 하지만 이모지가 보이기는 한다는 점이 중요하며, 이는 최소한으로 수용 가능한 결과입니다. "🎉"가 있어야 할 자리에 빈 사각형이 나타나는 것이 흑백 파티 폭죽이 나타나는 것보다 훨씬 나쁘기 때문입니다.
setSplitCharacter 수정 사항
올바른 폰트를 등록하더라도, iText는 때때로 이모지 코드포인트를 줄 바꿈(line split) 과정에서 분리하여 데이터가 손상되게 만들었습니다. 이모지는 종종 서로게이트 페어(surrogate pairs, 두 개의 char 값)로 표현됩니다. iText의 기본 분리기는 각 char를 독립적으로 취급하여 서로게이트 페어를 쪼개버립니다.
해결책: 서로게이트 페어를 분리하지 않는 커스텀 ISplitCharacter를 구현하는 것입니다:
public class EmojiSafeSplitCharacter implements ISplitCharacter {
@Override
public boolean isSplitCharacter(int start, int current, int end,
...
전투 3: 배경 이미지 SVG 호환성
버그
SVG 배경 이미지가 포함된 보고서 페이지를 PDF 모드로 렌더링하면, 페이지 전체를 덮는 불투명한 검은색 사각형이 나타났습니다. 이로 인해 그 아래에 있는 콘텐츠가 가려졌습니다.
근본 원인
SVG 배경은 배경색을 지정하기 위해 fill 속성을 가진 <rect> 요소를 사용했습니다. 브라우저 모드에서 이 요소들은 반투명한 오버레이(overlay)로 렌더링됩니다. 하지만 iText의 SVG 렌더러(renderer)에서는 fill-opacity와 rgba() 불투명도(opacity)가 부분적으로만 지원됩니다. 즉, 불투명도가 무시되어 fill이 완전히 불투명하게 렌더링됩니다.
따라서 PDF에서는 fill="rgba(0,0,0,0.6)" (반투명한 어두운 오버레이)가 fill="rgb(0,0,0)" (불투명한 검은색)로 변하게 됩니다.
해결책: 배경 SVG 클리닝 (Background SVG Cleaning)
PDF 렌더링 전에 불투명도와 호환되지 않는 요소를 제거하거나 조정하는 "세척(washing)" 단계를 도입했습니다:
public static String cleanSvgForPdf(String svg) {
// <rect> 배경 요소 제거 (PDF에서 불투명하게 렌더링됨)
svg = svg.replaceAll(
...
이 과정은 SVG→PNG 변환 전에 실행되어 배경이 올바르게 렌더링되도록 보장합니다.
Batik 네임스페이스 요구사항 (The Batik Namespace Requirement)
SVG와 관련된 또 다른 주의사항이 있습니다. Batik은 SVG가 표준 네임스페이스(namespace)를 선언할 것을 요구합니다:
<!-- 네임스페이스가 없는 경우: Batik에서 "not a valid SVG document" 오류 발생 -->
<svg viewBox="0 0 280 64">
...
...
해결책: 생성된 모든 SVG에 xmlns="http://www.w3.org/2000/svg"가 포함되도록 합니다. connect 모듈의 SKILL.md에 이 사항을 P0 규칙으로 추가했습니다.
forPdf 컨텍스트 플래그 (The forPdf Context Flag)
이 모든 해결책은 하나의 플래그인 context.isForPdf()에 의존합니다. 이 플래그는 전체 렌더링 파이프라인(pipeline)을 통해 흐릅니다:
public class RenderContext {
private boolean forPdf;
...
모든 위젯(widget)의 toHtml() 메서드는 이 플래그를 확인하고 그에 따라 경로를 지정합니다:
// ChartWidget.toHtml()
if (context.isForPdf()) {
svgData = SvgToPngConverter.convertToPngDataUrl(svgContent);
...
이러한 이중 경로 렌더링(dual-path rendering)을 통해 동일한 위젯 HTML이 브라우저와 PDF 모두에서 작동하며, 각 경로가 대상에 최적화되도록 보장합니다.
교훈 (Lessons Learned)
-
iText의 SVG 렌더러를 신뢰하지 마세요. iText에 전달하기 전에 SVG를 PNG로 변환하세요. Batik standalone이 iText 내부에 포함된 Batik보다 더 안정적입니다.
-
viewBox 차원은 선택 사항이 아닙니다. viewBox로부터 명시적인 차원이 없으면, PNG 변환 시 잘못된 크기의 이미지가 생성됩니다. 항상 viewBox를 파싱하여 사용하세요.
-
PDF 내의 이모지는 렌더링 문제가 아니라 폰트 라우팅 (font routing) 문제입니다. 해당 코드 포인트 (codepoints)를 포함하는 폰트가 필요하며, 서로게이트 페어 (surrogate pair) 분할을 방지해야 합니다. 컬러 이모지는 구현이 어려울 수 있지만, 가시적인 단색(monochrome) 이모지만으로도 충분합니다.
-
불투명도 (Opacity)는 PDF 변환 시 유지되지 않습니다.
rgba()및fill-opacity는 부분적으로 지원되거나 지원되지 않습니다. 렌더링 전에 SVG 배경을 정리하세요. -
forPdf플래그가 가장 깔끔한 라우팅 메커니즘입니다. 브라우저용과 PDF용 HTML 템플릿을 별도로 유지하는 대신, 대상에 따른 조건부 렌더링 (conditional rendering)을 사용하는 하나의 템플릿을 사용하세요.
시리즈 결론 (Series Conclusion)
이 10부작 시리즈는 모듈형 평가 엔진 (modular assessment engine)의 전체 아키텍처를 다루었습니다:
- 아키텍처 개요 (Architecture overview) — 6개 모듈, 1개의 플랜 에이전트 (plan agent), 스트리밍 실행
- 플래닝 엔진 (Planning engine) — 결정 행렬 (decision matrix), YAML 작업 목록, 지식 자기 완결성 (knowledge self-containment)
- 폼 모듈 (Form module) — 필드 유형, 인라인 채점 (inline scoring), 역문항 (reverse questions)
- 척도 모듈 (Scale module) — 차원 방향, 범위 연속성, 저장 시점 역전
- 연결 모듈 (Connect module) — 표지 변환, 시각적 스타일, SVG 일러스트레이션
- 리포트 모듈 (Report module) — 페이지 템플릿, 위젯 그리드, 변수 시스템, 로직 계층
- 전문가 모듈 (Expert module) — 프롬프트 프레임워크, 행동 레드라인 (behavior red lines), PATCH 모드
- 원클릭 파이프라인 (One-click pipeline) — 4단계 SSE 스트림, 로딩 UX, 입력 모드
- SSE 신뢰성 (SSE reliability) — ReentrantLock, 가상 스레드 (virtual threads), 타임아웃 처리
- PDF 렌더링 (PDF rendering) — SVG→PNG 변환, 이모지 폰트 라우팅, 불투명도 정리
이 작업을 가능하게 한 핵심 아키텍처 결정 사항들:
- AI가 코드가 아닌 CLI 명령어를 생성함 — 더 안전하고, 제어 가능하며, 검증하기 쉬움
- 각 기술(Skill)은 독립적임 — 자기 완결적인 지식이며, 작업 간 상호 참조가 없음
- 템플릿은 엔진에 의해 계산됨 — AI는 의도를 선언하고, 엔진이 구조를 구축함
- 스트리밍 실행 (Streaming execution) — 명령어가 생성되는 즉시 실행되어 시간을 40% 단축함
- 이중 경로 렌더링 (Dual-path rendering) — 하나의 HTML 템플릿으로 조건에 따라 브라우저 또는 PDF 출력
만약 여러분이 이와 유사한 것 — AI 기반 코드 생성, 모듈형 기술 시스템, 또는 PDF 렌더링 파이프라인 — 을 구축하고 있다면, 이 시리즈가 제가 겪었던 고통을 줄여줄 수 있기를 바랍니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기