HTML Layout Parser vs SVG foreignObject
Another common approach for rendering HTML on Canvas is using SVG <foreignObject> to wrap HTML content, then drawing the SVG to Canvas. This is a solid, practical choice in many cases and often looks great. While it works well for many cases, it has some limitations in specific scenarios that HTML Layout Parser addresses.
The SVG foreignObject Approach
// Common SVG foreignObject pattern
function renderHtmlToCanvas(html: string, canvas: HTMLCanvasElement) {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600">
<foreignObject width="800" height="600">
<div xmlns="http://www.w3.org/1999/xhtml">
${html}
</div>
</foreignObject>
</svg>
`;
const img = new Image();
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
img.onload = () => {
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
};
img.src = url;
}This approach has some limitations in certain scenarios.
Limitations of SVG foreignObject
1. Small Font Blur on Zoom
Problem: Small fonts (< 14px) become severely blurred when the canvas is scaled.
// SVG approach - blurry when zoomed
const svg = `
<svg width="800" height="600">
<foreignObject width="800" height="600">
<div style="font-size: 12px;">Small text</div>
</foreignObject>
</svg>
`;
// When canvas is scaled (e.g., zoom in)
ctx.scale(2, 2);
ctx.drawImage(svgImage, 0, 0);
// Result: Blurry, pixelated text ❌Why? This is a fundamental limitation of how browsers handle SVG foreignObject:
- Browser-controlled rasterization: The browser rasterizes the foreignObject content at the original SVG size (800x600 in this example)
- Fixed bitmap creation: This creates a fixed-resolution bitmap - you have no control over this process
- Bitmap scaling: When you scale the canvas, you're scaling this pre-rendered bitmap, not the original vector content
- Quality loss: Small fonts (10-14px) lose clarity because they're being magnified from a low-resolution source
This is not a bug - it's how SVG foreignObject is designed to work in browsers. The browser must rasterize HTML content to a bitmap before it can be used as an image source.
WASM Parser Solution:
// WASM approach - crisp at any zoom level
const layouts = parser.parse(html, { viewportWidth: 800 });
// Render at scaled size directly
ctx.scale(2, 2);
for (const char of layouts) {
ctx.font = `${char.fontSize}px ${char.fontFamily}`;
ctx.fillText(char.character, char.x, char.y);
}
// Result: Crisp, clear text ✅Key Advantage: With WASM parser, you render text directly at the target scale using Canvas native text rendering. While small fonts will still show some blur when heavily zoomed (this is a limitation of bitmap-based Canvas rendering), the quality is significantly better than SVG foreignObject because you're rendering fresh text at each zoom level rather than scaling a pre-rendered bitmap. This is especially important for small fonts (10-14px) which are common in UI text, labels, and annotations.
2. Black Background on Empty Tags
Problem: In WebView environments (especially on Android), certain empty tags (<br>, <hr>, empty <div>) render with unexpected black backgrounds.
// SVG foreignObject
const html = `
<div>Line 1</div>
<br>
<div>Line 2</div>
`;
// On some Android WebViews:
// ❌ Black rectangle appears where <br> is
// ❌ Inconsistent across devicesAffected Tags:
<br>- Line breaks<hr>- Horizontal rules- Empty
<div>,<p>,<span> - Self-closing tags
Device-Specific Issues:
- ❌ Black backgrounds on some Android devices
- ❌ Inconsistent across WebView versions
- ⚠️ iOS WebViews can also misrender in some cases
- ⚠️ Requires preprocessing special tags and compatibility handling
WASM Parser Solution:
// WASM approach - no rendering artifacts
const layouts = parser.parse(html, { viewportWidth: 800 });
// Only actual characters are rendered
// Empty tags don't produce visual artifacts ✅
for (const char of layouts) {
if (char.character.trim()) {
ctx.fillText(char.character, char.x, char.y);
}
}3. No Web Worker Support
Problem: SVG foreignObject requires DOM access, which is not available in Web Workers.
// ❌ Cannot use in Web Worker
// Web Worker context
self.onmessage = (e) => {
const html = e.data.html;
// Error: document is not defined
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
// ❌ DOM APIs not available in Worker
};Why This Matters:
- Canvas rendering is CPU-intensive
- Workers prevent UI blocking
- Essential for smooth 60fps animations
- Required for large documents
WASM Parser Solution:
// ✅ Works perfectly in Web Worker
import { HtmlLayoutParser } from 'html-layout-parser/worker';
self.onmessage = async (e) => {
const parser = new HtmlLayoutParser();
await parser.init('/workers/html-layout-parser/html_layout_parser.js');
// Load font
const fontData = e.data.fontData;
parser.loadFont(fontData, 'Arial');
// Parse in worker - no DOM needed
const layouts = parser.parse(e.data.html, {
viewportWidth: e.data.width
});
// Send layouts back to main thread
self.postMessage({ layouts });
};4. Security Restrictions
Problem: SVG foreignObject has strict security limitations:
// ❌ External resources blocked
const svg = `
<svg>
<foreignObject>
<div>
<img src="https://example.com/image.png">
<!-- Image won't load due to CORS -->
</div>
</foreignObject>
</svg>
`;
// ❌ External fonts blocked
const svg = `
<svg>
<foreignObject>
<div style="font-family: 'Custom Font'">
<!-- Font won't load -->
</div>
</foreignObject>
</svg>
`;WASM Parser Solution:
// ✅ Full control over resources
const fontData = await fetch('/fonts/custom.ttf')
.then(r => r.arrayBuffer());
parser.loadFont(new Uint8Array(fontData), 'Custom Font');
// Fonts are embedded, no CORS issues
const layouts = parser.parse(html, { viewportWidth: 800 });5. Browser-Controlled Rendering
Problem: With SVG foreignObject, you have no control over how the browser rasterizes content.
The browser decides:
- When to rasterize (timing)
- At what resolution (DPI)
- How to handle sub-pixel rendering
- Font hinting and anti-aliasing
This means:
- You cannot optimize for specific zoom levels
- You cannot pre-render at higher resolutions
- You cannot control quality vs performance tradeoffs
WASM Parser Solution:
// ✅ Full control over rendering
const layouts = parser.parse(html, { viewportWidth: 800 });
// You decide when and how to render
// Render at 2x for retina displays
const scale = window.devicePixelRatio;
ctx.scale(scale, scale);
// Render with custom quality settings
ctx.textRendering = 'optimizeLegibility';
ctx.font = `${char.fontSize}px ${char.fontFamily}`;
ctx.fillText(char.character, char.x, char.y);Comparison Table
| Feature | SVG foreignObject | HTML Layout Parser |
|---|---|---|
| System fonts | ✅ Automatic access | ⚠️ Must load fonts manually |
| Font setup | ✅ No setup needed | ⚠️ Requires explicit font loading |
| Small font clarity (10-14px) | ❌ Very blurry when zoomed | ✅ Better clarity when zoomed |
| Empty tag handling | ❌ Black backgrounds (Android) | ✅ No artifacts |
| Web Worker support | ❌ Requires DOM | ✅ Full support |
| External resources | ❌ CORS restrictions | ✅ Full control |
| Rendering control | ❌ Browser-controlled | ✅ Developer-controlled |
| Performance | ⚠️ Slow for large content | ✅ Fast WASM execution |
| Zoom quality | ❌ Scales pre-rendered bitmap | ✅ Re-renders at target scale |
Real-World Issues
Issue 1: Blurry Small Fonts
// User zooms in on canvas
canvas.style.transform = 'scale(2)';
// SVG foreignObject result:
// 12px font → Very blurry, pixelated ❌
// 10px font → Barely readable ❌
// WASM Parser result:
// Render at 2x scale directly
ctx.scale(2, 2);
ctx.font = '12px Arial';
ctx.fillText(char, x, y);
// Result: Better clarity, though still some blur at extreme zoom ✅
// (Canvas text rendering is bitmap-based, but fresh rendering is clearer)Issue 2: Android WebView Black Backgrounds
// HTML with line breaks
const html = `
<div>Paragraph 1</div>
<br>
<br>
<div>Paragraph 2</div>
`;
// SVG foreignObject on Android:
// [Text]
// [BLACK BOX] ← <br> renders as black
// [BLACK BOX] ← <br> renders as black
// [Text]
// WASM Parser:
// [Text]
// [Empty space] ← Correct
// [Empty space] ← Correct
// [Text]Issue 3: Worker Performance
// Rendering 10,000 characters
// Main thread (blocks UI):
// SVG foreignObject: 150ms + UI freeze ❌
// Web Worker (non-blocking):
// WASM Parser: 45ms, UI stays responsive ✅Migration Example
Before: SVG foreignObject
class CanvasRenderer {
async renderHtml(html: string, canvas: HTMLCanvasElement) {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600">
<foreignObject width="800" height="600">
<div xmlns="http://www.w3.org/1999/xhtml"
style="font-size: 12px;">
${html}
</div>
</foreignObject>
</svg>
`;
return new Promise((resolve, reject) => {
const img = new Image();
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
img.onload = () => {
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
resolve();
};
img.onerror = reject;
img.src = url;
});
}
}
// ❌ Problems:
// - Blurry small fonts when zoomed
// - Black backgrounds on Android
// - Can't use in Web Worker
// - Inconsistent across devicesAfter: WASM Parser
import { HtmlLayoutParser } from 'html-layout-parser';
class CanvasRenderer {
private parser: HtmlLayoutParser;
async init() {
this.parser = new HtmlLayoutParser();
await this.parser.init();
const fontData = await this.loadFont('/fonts/arial.ttf');
this.parser.loadFont(fontData, 'Arial');
}
renderHtml(html: string, canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d')!;
// Parse HTML
const layouts = this.parser.parse(html, {
viewportWidth: canvas.width
});
// Render each character
for (const char of layouts) {
ctx.font = `${char.fontSize}px ${char.fontFamily}`;
ctx.fillStyle = char.color;
ctx.fillText(char.character, char.x, char.y + char.fontSize);
}
}
private async loadFont(url: string): Promise<Uint8Array> {
const response = await fetch(url);
const buffer = await response.arrayBuffer();
return new Uint8Array(buffer);
}
}
// ✅ Benefits:
// - Crisp text at any zoom level
// - No rendering artifacts
// - Works in Web Workers
// - Consistent everywhereUse Cases Where SVG foreignObject Has Limitations
⚠️ Mobile Apps (React Native, Capacitor)
- Black background issues on some Android devices
- Inconsistent rendering across different WebView versions
- Poor zoom quality for small fonts
⚠️ Electron Apps
- SVG foreignObject may have rendering issues in some versions
- Security restrictions on external resources
⚠️ High-DPI Displays
- Small fonts become blurry when zoomed
- Pixelation visible on retina displays
⚠️ Zoomable Interfaces
- Quality degrades when zoomed in/out
- Not ideal for diagram editors, maps, etc.
⚠️ Web Workers
- Cannot use SVG foreignObject in workers (requires DOM)
- Large documents must be processed on main thread
When SVG foreignObject Works Well
SVG foreignObject is a good choice for:
- ✅ System fonts only - no need to load custom fonts
- ✅ Large fonts (> 16px) without zoom requirements
- ✅ Static, non-interactive content
- ✅ Desktop-only applications
- ✅ Simple layouts without empty tags
- ✅ Main thread rendering only
- ✅ Quick prototypes and demos
Key Advantage: SVG foreignObject can use system fonts automatically without any setup, making it very convenient for simple use cases.
When to Consider HTML Layout Parser
HTML Layout Parser is better suited for:
- ✅ Small fonts (10-14px) with zoom support
- ✅ Mobile apps (React Native, Capacitor)
- ✅ High-DPI displays requiring crisp text
- ✅ Zoomable interfaces (diagram editors, maps)
- ✅ Web Worker-based rendering
- ✅ Cross-platform consistency requirements
Conclusion
SVG foreignObject is a solid choice in many cases, especially for simple layouts, but it has limitations in specific scenarios:
- ⚠️ Small font blur when zoomed - browser rasterizes at original size
- ⚠️ Black backgrounds on some Android WebViews for empty tags
- ⚠️ No Web Worker support - requires DOM access
- ⚠️ Security restrictions on external resources
- ⚠️ No rendering control - browser decides quality and timing
SVG foreignObject Advantages:
- ✅ System fonts work automatically - no font loading required
- ✅ Simple setup - works out of the box
- ✅ Convenient for prototyping - minimal code needed
HTML Layout Parser provides an excellent alternative when you need:
- ✅ Better small font rendering - clearer 10-14px text when zoomed (re-renders at target scale)
- ✅ No rendering artifacts on any device
- ✅ Full Web Worker support for performance
- ✅ Complete control over fonts and rendering
- ✅ Developer-controlled quality - you decide when and how to render
Trade-off: HTML Layout Parser requires manual font loading (you must explicitly load font files), while SVG foreignObject can use system fonts automatically. This makes SVG foreignObject more convenient for simple cases, but HTML Layout Parser provides better control and consistency across platforms.
Important Recommendation
If your application runs on the main thread (not in Web Worker/Node.js environment), we strongly recommend using SVG foreignObject first. It's simpler, requires no font loading, and works out of the box.
Only consider HTML Layout Parser when you need:
- ✅ Rendering in Web Workers (SVG foreignObject doesn't support this)
- ✅ Rendering in Node.js environment
- ✅ Significant zoom levels with high clarity requirements for small fonts
- ✅ Complete control over rendering process and fonts
Choose based on your needs:
- SVG foreignObject: Simple cases with large fonts, system fonts only, and no significant zoom
- HTML Layout Parser: Applications requiring better small text quality when zoomed, custom fonts, or significant zoom levels
See Also
- vs Range API - Comparison with browser Range API
- vs Canvas measureText - Comparison with Canvas measureText
- Canvas Rendering - How to render parsed layouts
- Web Worker Example - Using parser in workers