Export System Architecture

How tabviz generates static images from both R and the browser

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): string

Design constraints:

  1. No DOM access — Cannot use document, window, canvas.measureText(), etc.
  2. No external dependencies — Pure TypeScript, bundled as standalone IIFE
  3. 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":

  1. Measure the header text
  2. Measure all cell values using the proper formatted text (e.g., “0.76 (0.68, 0.85)” for intervals)
  3. 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 only

R Integration

save_plot()

save_plot(spec, "output.svg", width = 800, height = NULL)

Process:

  1. Serialize WebSpec to JSON via serialize_spec()
  2. Create V8 context and load svg-generator.js
  3. Call generateSVG(specJson, options)
  4. 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

DownloadButton.svelte

The download button calls the same generator directly:

import { exportToSVG, exportToPNG } from "$lib/export";

async function handleExportSVG() {
  const svgString = exportToSVG(store.spec);
  // Create blob and trigger download
}

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:

  1. Add rendering logic in svg-generator.ts:
function renderTableRow(...) {
  // ...existing code...

  if (col.type === "myNewType") {
    // Render your custom SVG elements
    lines.push(`<rect .../>`);
  }
}
  1. Rebuild both bundles:
bun run build

Customizing 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 build

Text alignment looks off

The text width estimation is character-aware but may still be slightly off for unusual fonts. Solutions:

  1. Explicitly set column widths with col_text("field", width = 150)
  2. Use a standard font family in the theme (Inter, Arial, system-ui)
  3. Increase or decrease padding via the AUTO_WIDTH.PADDING constant in rendering-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.