Series: Building a Modular Assessment Engine (10/10)
The final boss of this project: PDF rendering. The report module generates HTML, which iText converts to PDF. In theory, HTML → PDF is straightforward. In practice, it's a warzone of SVG crashes, emoji invisibility, and font routing nightmares. This post covers the three rendering battles and how each was won.
Battle 1: The SVG NPE That Crashed Every Report
The Bug
Reports with charts (radar charts, bar charts) would crash during PDF generation with:
com.itextpdf.kernel.PdfException: Cannot convert PdfArray to float array.
at com.itextpdf.kernel.pdf.PdfArray.toFloatArray(...)
at com.itextpdf.svg.converter.SvgConverter.drawToPdf(...)
The NPE occurred in iText's SVG renderer (Batik-based) when it tried to parse SVG path data. The crash was total — no PDF was produced. The user saw "Report generation failed."
The Root Cause
The chart widgets generated SVG as data:image/svg+xml;base64,... URLs embedded in <img> tags. This works fine in browsers. But iText's SVG renderer has known issues with:
-
Complex
<path>dattributes — Batik'sPdfArray.toFloatArray()fails on certain path command sequences - CSS-in-SVG attributes — iText doesn't fully support CSS properties on SVG elements
-
<text>withtext-anchor— partially supported, causes PdfNumber parsing errors
Four different widget types were affected:
-
ChartWidget(general charts) -
FieldChartWidget(per-field charts) -
ScaleChartWidget(dimension score charts) -
TrainExpertWidget(expert QR code widget)
The Fix: SVG → PNG Conversion
Instead of feeding SVG to iText's fragile renderer, convert SVG to PNG before PDF generation:
// In WidgetEntity.toHtml()
if (context.isForPdf()) {
// PDF mode: convert SVG to PNG data URL
String pngDataUrl = SvgToPngConverter.convertToPngDataUrl(svgContent);
return "<img src=\"" + pngDataUrl + "\" />";
} else {
// Browser mode: keep SVG as-is
return "<img src=\"data:image/svg+xml;base64,"
+ Base64.encode(svgContent.getBytes()) + "\" />";
}
The context.isForPdf() flag is set by the report engine when generating PDFs. Every widget that produces SVG checks this flag and routes to the PNG converter.
The SvgToPngConverter
The converter uses Batik (the same library iText uses internally, but in standalone mode):
public static String convertToPngDataUrl(String svgContent) {
// Parse SVG with Batik
SAXSVGDocumentFactory factory = new SAXSVGDocumentFactory(...);
SVGDocument doc = factory.createSVG(null, new StringReader(svgContent));
// Render to BufferedImage
SVGConverter converter = new SVGConverter();
converter.setDestinationType(DestinationType.PNG);
// ... configure dimensions from viewBox
// Convert to base64 data URL
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(bufferedImage, "PNG", baos);
return "data:image/png;base64," + Base64.encode(baos.toByteArray());
}
The key insight: Batik standalone (for PNG conversion) is more stable than Batik embedded in iText (for direct SVG rendering). The same SVG that crashes iText's PdfArray parser renders fine as a standalone PNG.
The viewBox Dimension Bug
But the PNG converter had its own bug. SVGs specify their dimensions through viewBox (e.g., viewBox="0 0 280 64"). The converter was ignoring viewBox and using default full-page dimensions:
SVG viewBox: 280×64 (wide and short)
PNG output: 1024×768 (default, wrong aspect ratio)
→ Chart renders as a tiny strip in the middle of a huge transparent PNG
→ PDF shows a microscopic chart
The fix: extract width and height from viewBox and use them for PNG dimensions:
// Parse viewBox="minX minY width height"
Pattern vbPattern = Pattern.compile("viewBox\\s*=\\s*['\"]([^'\"]+)['\"]");
Matcher m = vbPattern.matcher(svgContent);
if (m.find()) {
String[] parts = m.group(1).split("\\s+");
float vbWidth = Float.parseFloat(parts[2]);
float vbHeight = Float.parseFloat(parts[3]);
// Use viewBox dimensions, scaled up for resolution
converter.setWidth(vbWidth * 2); // 2x for retina-quality PNG
converter.setHeight(vbHeight * 2);
}
Battle 2: Emoji Rendering in PDF
The Bug
The report HTML contains emoji — 🎉 in congratulations, 📊 in chart titles, ✅ in checklists. In the browser, these render fine. In the PDF, they show as blank squares (tofu) or disappear entirely.
The Root Cause
iText uses font registration to render text. The default fonts (Helvetica, Times-Roman) don't include emoji glyphs. When iText encounters an emoji codepoint, it looks for a font that covers it, finds none, and renders nothing.
The Font Routing Problem
The solution sounds simple: register an emoji font (like Noto Color Emoji or Apple Color Emoji). But it's not that simple because:
Color emoji fonts (Noto Color Emoji, Apple Color Emoji) use bitmap/COLR tables. iText's font engine doesn't fully support color emoji — it renders them as monochrome or not at all.
Monochrome emoji fonts (Symbola, DejaVu Sans) cover emoji codepoints but render them as simple black-and-white symbols. Better than nothing, but not great.
Font fallback chains — iText needs explicit font registration for each Unicode range. There's no automatic fallback like browsers have.
The Solution: Font Coverage Matrix
Register multiple fonts covering different Unicode ranges:
PdfFontFactory.register("/fonts/NotoSans-Regular.ttf", "noto-sans");
PdfFontFactory.register("/fonts/Symbola.ttf", "symbola-emoji");
// ... other fonts
// Font routing: for each character, find a font that has it
public PdfFont getFontForChar(char c) {
if (Character.isSupplementaryCodePoint(c)) {
// Emoji range (U+1F000-U+1FAFF, U+2600-U+27BF)
if (isEmojiCodepoint(c)) {
return symbolaEmojiFont; // Monochrome but visible
}
}
return defaultFont; // Noto Sans for regular text
}
The approach is imperfect — emoji render as black-and-white symbols, not color. But they're visible, which is the minimum acceptable outcome. A blank square where "🎉" should be is worse than a monochrome party popper.
The setSplitCharacter Fix
Even with the right font registered, iText would sometimes split emoji codepoints across lines, corrupting them. Emoji are often represented as surrogate pairs (two char values). iText's default splitter treats each char independently, splitting surrogate pairs.
The fix: a custom ISplitCharacter that doesn't split surrogate pairs:
public class EmojiSafeSplitCharacter implements ISplitCharacter {
@Override
public boolean isSplitCharacter(int start, int current, int end,
char[] cc, PdfChunk[] ck) {
// Don't split between surrogate pairs
if (Character.isHighSurrogate(cc[current])
&& current + 1 < cc.length
&& Character.isLowSurrogate(cc[current + 1])) {
return false;
}
return defaultSplit.isSplitCharacter(start, current, end, cc, ck);
}
}
Battle 3: Background Image SVG Compatibility
The Bug
Report pages with SVG background images would render with a black opaque rectangle covering the entire page in PDF mode. The content underneath was hidden.
The Root Cause
The SVG backgrounds used <rect> elements with fill for the background color. In browser mode, these render as semi-transparent overlays. In iText's SVG renderer, fill-opacity and rgba() opacity are partially supported — the opacity is ignored, and the fill renders as fully opaque.
So fill="rgba(0,0,0,0.6)" (semi-transparent dark overlay) becomes fill="rgb(0,0,0)" (solid black) in PDF.
The Fix: Background SVG Cleaning
A "washing" step before PDF rendering that removes or adjusts opacity-incompatible elements:
public static String cleanSvgForPdf(String svg) {
// Remove <rect> background elements (they render as opaque in PDF)
svg = svg.replaceAll(
"<rect[^>]*fill=\"rgba\\([^)]+\\)\"[^>]*/>", "");
// Replace rgba() with solid color + reduced opacity attribute
// (iText handles opacity attribute better than rgba())
// Remove CSS background-image references
svg = svg.replaceAll(
"background-image:\\s*url\\([^)]+\\)", "");
return svg;
}
This runs before the SVG→PNG conversion, ensuring the background renders correctly.
The Batik Namespace Requirement
One more SVG gotcha: Batik requires the SVG to declare the standard namespace:
<!-- Without namespace: Batik throws "not a valid SVG document" -->
<svg viewBox="0 0 280 64">
...
</svg>
<!-- With namespace: works -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 64">
...
</svg>
The fix: ensure all generated SVG includes xmlns="http://www.w3.org/2000/svg". The SKILL.md for the connect module now includes this as a P0 rule.
The forPdf Context Flag
All these fixes hang off one flag: context.isForPdf(). This flag flows through the entire rendering pipeline:
public class RenderContext {
private boolean forPdf;
public boolean isForPdf() { return forPdf; }
// In PDF generation:
context.setForPdf(true);
String html = widget.toHtml(context); // Widgets check isForPdf()
// → SVG becomes PNG data URL
// → Opacity-incompatible elements cleaned
// → Font routing uses emoji-safe fonts
}
Every widget's toHtml() method checks this flag and routes accordingly:
// ChartWidget.toHtml()
if (context.isForPdf()) {
svgData = SvgToPngConverter.convertToPngDataUrl(svgContent);
} else {
svgData = "data:image/svg+xml;base64," + Base64.encode(svgContent.getBytes());
}
// Background SVG
if (context.isForPdf()) {
svgContent = SvgCleaner.cleanForPdf(svgContent);
}
This dual-path rendering ensures the same widget HTML works in both browser and PDF, with each path optimized for its target.
Lessons Learned
Don't trust iText's SVG renderer. Convert SVG to PNG before feeding it to iText. Batik standalone is more stable than Batik-in-iText.
viewBox dimensions are not optional. Without explicit dimensions from viewBox, PNG conversion produces wrong-sized images. Always parse and use viewBox.
Emoji in PDF is a font routing problem, not a rendering problem. You need a font that covers the codepoints, and you need to prevent surrogate pair splitting. Color emoji may not be achievable, but visible monochrome emoji is good enough.
Opacity doesn't survive PDF conversion.
rgba()andfill-opacityare partially or unsupported. Clean SVG backgrounds before rendering.The
forPdfflag is the cleanest routing mechanism. Instead of maintaining separate HTML templates for browser and PDF, use one template with conditional rendering based on the target.
Series Conclusion
This 10-part series covered the full architecture of a modular assessment engine:
- Architecture overview — six modules, one plan agent, streaming execution
- Planning engine — decision matrix, YAML task list, knowledge self-containment
- Form module — field types, inline scoring, reverse questions
- Scale module — dimension direction, range continuity, storage-time reversal
- Connect module — cover page conversion, visual styles, SVG illustrations
- Report module — page templates, widget grid, variable system, logic tiers
- Expert module — prompt framework, behavior red lines, PATCH mode
- One-click pipeline — four-phase SSE stream, loading UX, input modes
- SSE reliability — ReentrantLock, virtual threads, timeout handling
- PDF rendering — SVG→PNG conversion, emoji font routing, opacity cleaning
The core architectural decisions that made this work:
- AI generates CLI commands, not code — safer, more controllable, easier to validate
- Each skill is independent — self-contained knowledge, no cross-task references
- Templates are engine-computed — the AI declares intent, the engine builds structure
- Streaming execution — commands run as they're generated, cutting time by 40%
- Dual-path rendering — one HTML template, conditional browser/PDF output
If you're building something similar — AI-driven code generation, modular skill systems, or PDF rendering pipelines — I hope this series saves you some of the pain I went through.
Top comments (0)