DEV Community

zepubo-code
zepubo-code

Posted on

JPG to SVG: How Vectorization Works in the Browser (No Server Required)

When designers ask "can I convert this JPG to SVG?", they usually mean one of two things:

  1. Embed the JPG inside an SVG wrapper (fast, lossless, but not truly vector)
  2. Trace the JPG into actual vector paths (slow, lossy, but infinitely scalable)

Both are valid. They serve completely different use cases. In this post I'll show you how to implement both entirely in the browser — no server, no Illustrator, no backend.

Why SVG?

SVG (Scalable Vector Graphics) is resolution-independent. A 100-byte SVG can render crisply on a 4K billboard or a smartwatch — because it's math, not pixels.

JPG is the opposite: a fixed grid of pixels. Zoom in enough and it falls apart.

Use cases where you need SVG from a JPG:

  • Logos that need to scale to any size
  • Icons for UI frameworks
  • Print designs (banners, merchandise)
  • Laser cutting / CNC files
  • CSS/HTML animations on vector shapes

Method 1: Embed JPG Inside SVG (Simple)

This wraps your JPG in an SVG container. It's not "true" vectorization — the image is still raster underneath — but it gives you an .svg file that works in vector workflows and scales without pixelation at the container level.

async function jpgToSvgEmbed(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.onload = (e) => {
      const base64 = e.target.result; // data:image/jpeg;base64,...

      // Get image dimensions
      const img = new Image();
      img.onload = () => {
        const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
     xmlns:xlink="http://www.w3.org/1999/xlink"
     width="${img.width}"
     height="${img.height}"
     viewBox="0 0 ${img.width} ${img.height}">
  <image href="${base64}"
         width="${img.width}"
         height="${img.height}"
         x="0"
         y="0"/>
</svg>`;

        const blob = new Blob([svg], { type: 'image/svg+xml' });
        resolve(blob);
      };

      img.src = base64;
    };

    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}
Enter fullscreen mode Exit fullscreen mode

When to use this: Quick conversion, logos on white backgrounds, situations where you just need an .svg file extension and basic scalability.

Limitation: Not editable in Illustrator/Figma as vector paths. Still a raster image inside an SVG wrapper.

Method 2: True Vectorization via Canvas Tracing

This is where it gets interesting. Real vectorization converts pixel regions into mathematical path descriptions. The classic algorithm for this is potrace — originally a C library, now ported to JavaScript.

The Algorithm (Simplified)

1. Convert image to grayscale
2. Apply threshold → binary (black/white) image  
3. Trace boundaries between black/white regions
4. Approximate boundaries as Bezier curves
5. Output as SVG <path> elements
Enter fullscreen mode Exit fullscreen mode

Implementation with Potrace.js

// Load from CDN
// <script src="https://cdn.jsdelivr.net/npm/potrace@2.1.8/potrace.js"></script>

async function jpgToSvgTrace(file, options = {}) {
  const {
    threshold = 128,    // 0-255, cutoff between black/white
    turdSize = 2,       // ignore features smaller than this (noise reduction)
    alphaMax = 1,       // corner threshold (0=all corners, 1.33=all curves)
    optCurve = true,    // optimize curves
    optTolerance = 0.2  // curve optimization tolerance
  } = options;

  // Step 1: Load image onto canvas
  const canvas = await loadImageToCanvas(file);
  const ctx = canvas.getContext('2d');
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

  // Step 2: Convert to grayscale + threshold
  const binaryData = toBinary(imageData, threshold);

  // Step 3: Run potrace
  return new Promise((resolve, reject) => {
    Potrace.loadImageData(binaryData, canvas.width, canvas.height);
    Potrace.process({
      turnpolicy: Potrace.TURNPOLICY_MINORITY,
      turdsize: turdSize,
      alphamax: alphaMax,
      opticurve: optCurve,
      opttolerance: optTolerance,
    });

    const svg = Potrace.getSVG(1); // scale factor
    const blob = new Blob([svg], { type: 'image/svg+xml' });
    resolve(blob);
  });
}

function loadImageToCanvas(file) {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => {
      const canvas = document.createElement('canvas');
      canvas.width = img.width;
      canvas.height = img.height;
      canvas.getContext('2d').drawImage(img, 0, 0);
      resolve(canvas);
    };
    img.src = URL.createObjectURL(file);
  });
}

function toBinary(imageData, threshold) {
  const data = imageData.data;
  const binary = new Uint8ClampedArray(imageData.width * imageData.height);

  for (let i = 0; i < binary.length; i++) {
    const r = data[i * 4];
    const g = data[i * 4 + 1];
    const b = data[i * 4 + 2];
    // Luminance formula
    const gray = 0.299 * r + 0.587 * g + 0.114 * b;
    binary[i] = gray < threshold ? 1 : 0; // 1 = black, 0 = white
  }

  return binary;
}
Enter fullscreen mode Exit fullscreen mode

The Threshold Parameter — Most Important Setting

The threshold value is what determines the output quality most dramatically. It decides which pixels become "black" (traced as vector) and which become "white" (background).

// Low threshold (50) — only very dark pixels become vector
// Result: sparse, loses detail
const svgLight = await jpgToSvgTrace(file, { threshold: 50 });

// Medium threshold (128) — balanced (usually best for logos)
const svgMedium = await jpgToSvgTrace(file, { threshold: 128 });

// High threshold (200) — most pixels become vector
// Result: dense, filled-in, can look solid black
const svgDark = await jpgToSvgTrace(file, { threshold: 200 });
Enter fullscreen mode Exit fullscreen mode

Pro tip: For logos on white backgrounds, start at threshold 128. For photos, you usually want lower (80-100) to pick up only the dominant subject.

Putting It All Together — With a Live Preview

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>JPG to SVG Converter</title>
</head>
<body>
  <h1>JPG to SVG Converter</h1>

  <input type="file" id="fileInput" accept=".jpg,.jpeg" />

  <div>
    <label>Mode:</label>
    <select id="mode">
      <option value="embed">Embed (fast, any image)</option>
      <option value="trace">Trace (true vector, best for logos)</option>
    </select>
  </div>

  <div id="traceOptions" style="display:none">
    <label>Threshold: <span id="thresholdVal">128</span></label>
    <input type="range" id="threshold" min="1" max="254" value="128">
  </div>

  <button id="convertBtn" disabled>Convert to SVG</button>
  <div id="preview"></div>

  <script src="https://cdn.jsdelivr.net/npm/potrace@2.1.8/potrace.js"></script>
  <script>
    const modeSelect = document.getElementById('mode');
    const traceOptions = document.getElementById('traceOptions');
    const thresholdInput = document.getElementById('threshold');
    const thresholdVal = document.getElementById('thresholdVal');
    const convertBtn = document.getElementById('convertBtn');
    const preview = document.getElementById('preview');
    let currentFile = null;

    modeSelect.addEventListener('change', () => {
      traceOptions.style.display = modeSelect.value === 'trace' ? 'block' : 'none';
    });

    thresholdInput.addEventListener('input', () => {
      thresholdVal.textContent = thresholdInput.value;
    });

    document.getElementById('fileInput').addEventListener('change', (e) => {
      currentFile = e.target.files[0];
      convertBtn.disabled = !currentFile;
    });

    convertBtn.addEventListener('click', async () => {
      if (!currentFile) return;
      convertBtn.textContent = 'Converting...';
      convertBtn.disabled = true;

      try {
        let blob;
        if (modeSelect.value === 'embed') {
          blob = await jpgToSvgEmbed(currentFile);
        } else {
          blob = await jpgToSvgTrace(currentFile, {
            threshold: parseInt(thresholdInput.value)
          });
        }

        // Preview
        const url = URL.createObjectURL(blob);
        preview.innerHTML = `<img src="${url}" style="max-width:400px">`;

        // Download
        const link = document.createElement('a');
        link.href = url;
        link.download = currentFile.name.replace(/\.[^.]+$/, '.svg');
        link.click();

      } catch (err) {
        console.error(err);
        preview.textContent = 'Error: ' + err.message;
      }

      convertBtn.textContent = 'Convert to SVG';
      convertBtn.disabled = false;
    });

    // (jpgToSvgEmbed and jpgToSvgTrace functions from above go here)
  </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Performance & Browser Limits

Canvas size limits apply here too. Safari caps canvas at ~16,384px on one axis. For large images, scale down before tracing:

function scaleCanvas(canvas, maxSize = 2000) {
  if (canvas.width <= maxSize && canvas.height <= maxSize) return canvas;

  const scale = maxSize / Math.max(canvas.width, canvas.height);
  const scaled = document.createElement('canvas');
  scaled.width = Math.round(canvas.width * scale);
  scaled.height = Math.round(canvas.height * scale);
  scaled.getContext('2d').drawImage(canvas, 0, 0, scaled.width, scaled.height);
  return scaled;
}
Enter fullscreen mode Exit fullscreen mode

Processing time scales with image complexity and resolution. A 500x500 logo traces in ~200ms. A 2000x2000 photo can take 3-5 seconds. Consider using a Web Worker for large files to avoid blocking the UI:

// worker.js
self.onmessage = async (e) => {
  const { binaryData, width, height, options } = e.data;
  // Run potrace here
  self.postMessage({ svg: result });
};
Enter fullscreen mode Exit fullscreen mode

When Client-Side Vectorization Falls Short

Browser-based tracing works well for:

  • Logos and icons on clean backgrounds
  • Line art and illustrations
  • QR codes and barcodes
  • Simple shapes

It struggles with:

  • Complex photographs (too many color regions)
  • Very detailed illustrations
  • Images with gradients

For these, server-side tools like Inkscape's autotrace or Adobe's vectorization engine produce much better results — but at the cost of privacy and server infrastructure.

Real-World Tool

If you want to try this without building it yourself, I built a free browser-based JPG to SVG converter at OneWeeb. Both embed and trace modes, threshold slider, live preview, and your files never leave your device.


Wrapping Up

Client-side vectorization is genuinely useful for a large class of images — especially logos, icons, and simple graphics. The key is understanding that you're doing threshold-based tracing, not magic AI upscaling.

The threshold parameter is everything. Spend 30 seconds tuning it and you'll get dramatically better results than the default.

For complex photos, accept the limitations and either use a server-side tool or embrace the "artistic" look that heavy vectorization produces.


Building anything interesting with SVG or the Canvas API? Drop it in the comments — always curious what people are making.

Top comments (0)