DEV Community

Keo Fung | FormLM
Keo Fung | FormLM

Posted on

[10/10]PDF Rendering War: SVG-to-PNG Conversion, Emoji Font Routing, and the iText NPE

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(...)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Complex <path> d attributes — Batik's PdfArray.toFloatArray() fails on certain path command sequences
  2. CSS-in-SVG attributes — iText doesn't fully support CSS properties on SVG elements
  3. <text> with text-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()) + "\" />";
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.

  2. Monochrome emoji fonts (Symbola, DejaVu Sans) cover emoji codepoints but render them as simple black-and-white symbols. Better than nothing, but not great.

  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

This dual-path rendering ensures the same widget HTML works in both browser and PDF, with each path optimized for its target.

Lessons Learned

  1. 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.

  2. viewBox dimensions are not optional. Without explicit dimensions from viewBox, PNG conversion produces wrong-sized images. Always parse and use viewBox.

  3. 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.

  4. Opacity doesn't survive PDF conversion. rgba() and fill-opacity are partially or unsupported. Clean SVG backgrounds before rendering.

  5. The forPdf flag 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:

  1. Architecture overview — six modules, one plan agent, streaming execution
  2. Planning engine — decision matrix, YAML task list, knowledge self-containment
  3. Form module — field types, inline scoring, reverse questions
  4. Scale module — dimension direction, range continuity, storage-time reversal
  5. Connect module — cover page conversion, visual styles, SVG illustrations
  6. Report module — page templates, widget grid, variable system, logic tiers
  7. Expert module — prompt framework, behavior red lines, PATCH mode
  8. One-click pipeline — four-phase SSE stream, loading UX, input modes
  9. SSE reliability — ReentrantLock, virtual threads, timeout handling
  10. 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)