Export System Architecture
Overview
tabviz uses a unified rendering architecture for static image export. The same JavaScript SVG generator powers both:
- R-side:
save_plot()function (via the V8 package) - Web-side: Download button in interactive plots
This ensures consistent output regardless of where the export is triggered.
┌─────────────────────────────────────────────────────────┐
│ svg-generator.ts │
│ (Pure-data SVG generator, no DOM needed) │
└──────────────────────┬──────────────────────────────────┘
│
┌───────────────┴───────────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Web (browser) │ │ R (via V8) │
│ DownloadButton │ │ save_plot() │
└──────────────────┘ └──────────────────┘
Why This Design?
Single Source of Truth
Instead of maintaining two separate renderers (one in R, one in JavaScript), we have a single TypeScript implementation that:
- Reduces code duplication
- Guarantees visual consistency between exports
- Makes bug fixes and improvements apply to both platforms
No Browser Dependency in R
The SVG generator is DOM-free — it takes WebSpec JSON data and outputs an SVG string through pure computation. This means:
- Works in R via the lightweight V8 JavaScript engine
- No need for headless Chrome/Chromium
- Fast execution without browser overhead
- Works offline without any system dependencies beyond V8
Components
svg-generator.ts
Located at srcjs/src/lib/svg-generator.ts, this is the core rendering engine.
Key functions:
// Main entry point
generateSVG(spec: WebSpec, options?: ExportOptions): string
// Internal helpers
computeLayout(spec: WebSpec): Layout
renderTableColumns(rows, columns, layout): string
renderForestPlot(rows, xScale, layout, theme): string
renderInterval(row, yPosition, xScale, theme): string
renderDiamond(point, lower, upper, yPosition, ...): string
renderAxis(xScale, layout, theme, axisLabel): stringDesign constraints:
- No DOM access — Cannot use
document,window,canvas.measureText(), etc. - No external dependencies — Pure TypeScript, bundled as standalone IIFE
- Deterministic output — Same input always produces same SVG
Text Measurement and Auto-Width
Since V8 has no DOM, we can’t use canvas.measureText() for precise text width calculation. Instead, width-utils.ts provides character-aware estimation:
function estimateTextWidth(text: string, fontSize: number): number {
let width = 0;
for (const char of text) {
if ("il1.,;:|!()[]{}' ".includes(char)) {
width += fontSize * 0.35; // Narrow characters
} else if ("mwMW@%".includes(char)) {
width += fontSize * 0.85; // Wide characters
} else if (char >= "0" && char <= "9") {
width += fontSize * 0.6; // Tabular numbers
} else {
width += fontSize * 0.55; // Average characters
}
}
return width;
}Auto-width calculation: Both web and SVG renderers now calculate column widths from actual content. For each column with width="auto":
- Measure the header text
- Measure all cell values using the proper formatted text (e.g., “0.76 (0.68, 0.85)” for intervals)
- Apply padding (28px) and constraints (min 60px, max 600px)
This ensures columns are wide enough to display their content without truncation, matching the behavior of the interactive web renderer.
Build Outputs
The SVG generator is bundled twice:
| File | Purpose | Size |
|---|---|---|
inst/htmlwidgets/tabviz.js |
Full widget bundle (includes Svelte UI) | ~142 KB |
inst/js/svg-generator.js |
Standalone for V8 (export only) | ~11 KB |
Build commands:
cd srcjs
bun run build # Both bundles
bun run build:widget # Widget only
bun run build:v8 # V8 bundle onlyR Integration
save_plot()
save_plot(spec, "output.svg", width = 800, height = NULL)Process:
- Serialize WebSpec to JSON via
serialize_spec() - Create V8 context and load
svg-generator.js - Call
generateSVG(specJson, options) - Write SVG string to file (or convert to PDF/PNG via rsvg)
Dependencies:
V8— JavaScript engine (Suggests)rsvg— SVG to PDF/PNG conversion (Suggests, optional)
Code Path
save_plot(spec, file)
│
├── serialize_spec(spec) # R: WebSpec → JSON
│
├── generate_svg_v8(json) # R: Load V8, call JS
│ │
│ └── generateSVG() # JS: JSON → SVG string
│
└── writeLines() / rsvg # R: Save to file
Web Integration
export.ts
Thin wrapper around svg-generator:
import { generateSVG, svgToBlob } from "./svg-generator";
export function exportToSVG(spec: WebSpec): string {
return generateSVG(spec);
}
export async function exportToPNG(spec: WebSpec, scale = 2): Promise<Blob> {
const svgString = generateSVG(spec);
return svgToBlob(svgString, scale); // Render to canvas, export
}SVG Output Structure
The generated SVG follows this structure:
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="400">
<!-- Background -->
<rect fill="#ffffff" width="100%" height="100%"/>
<!-- Title/Subtitle -->
<text x="12" y="24" font-size="16" font-weight="bold">Title</text>
<!-- Column headers -->
<text x="22" y="52">Study</text>
<text x="320" y="52">Effect (95% CI)</text>
<!-- Header border -->
<line x1="12" x2="788" y1="60" y2="60" stroke="#e2e8f0"/>
<!-- Reference line (null value) -->
<line x1="400" y1="60" x2="400" y2="340"
stroke="#94a3b8" stroke-dasharray="4"/>
<!-- Data rows -->
<g class="interval">
<line x1="350" x2="450" y1="80" y2="80" stroke="#475569"/>
<rect x="394" y="74" width="12" height="12" fill="#22c55e"/>
</g>
<!-- Summary diamond -->
<polygon points="360,320 400,310 440,320 400,330"
fill="#2563eb" stroke="#1d4ed8"/>
<!-- Axis -->
<line x1="200" x2="600" y1="360" y2="360" stroke="#e2e8f0"/>
<text x="400" y="380" text-anchor="middle">Odds Ratio</text>
</svg>Extending the System
Adding New Column Types
To support a new column type in exports:
- Add rendering logic in
svg-generator.ts:
function renderTableRow(...) {
// ...existing code...
if (col.type === "myNewType") {
// Render your custom SVG elements
lines.push(`<rect .../>`);
}
}- Rebuild both bundles:
bun run buildCustomizing Export Options
The ExportOptions interface can be extended:
interface ExportOptions {
width?: number;
height?: number;
scale?: number;
backgroundColor?: string;
// Add new options here
}Troubleshooting
“SVG generator JavaScript file not found”
The V8 bundle hasn’t been built. Run:
cd srcjs && bun run buildText alignment looks off
The text width estimation is character-aware but may still be slightly off for unusual fonts. Solutions:
- Explicitly set column widths with
col_text("field", width = 150) - Use a standard font family in the theme (Inter, Arial, system-ui)
- Increase or decrease padding via the
AUTO_WIDTH.PADDINGconstant inrendering-constants.ts
Colors don’t match the interactive plot
Ensure you’re using the same theme. The generator resolves all CSS variables to literal color values at export time.