Calculate Outline of SVG Path with Stroke

190 Views Asked by At

I have an SVG path, for example

<path id="stroke" d="M1 1V8" fill="none" stroke="#FFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>

and what I would like some kind of function, that takes in the path (M1 1V8) and the stroke width (2) and returns it's outline, so that if I put the outline in

<path id="outline" d="..." fill="#FFF" fill-rule="nonzero"/>

it will look the exact same as the original stroke path. So the outline of my example stroke (M1 1V8) would have been

M0 1L0 9C0 9.552 0.448 10 1 10C1.552 10 2 9.552 2 9L2 1C2 0.448 1.552 0 1 0C0.448 0 0 0.448 0 1

But the problem is, that I have no idea, how to do this when multiple Bézier curves cross, like for example in M1 1Q1 5 9 9M9 1Q9 5 1 9, or when there is a hole, like for example in M1 1H8V8H1Z.

I have spent hours searching the internet for how this could be done and libraries that can do it, however, all I was able to find was Maker.js, however, while it can calculate the outline just like I want, it will often fill in holes (like for example M1 1H8V8H1Z) and it will often break and just ignore some line segments and adding tiny offsets (like .001) to points will often cause it to just cut of half the outline. I used the browser version of Maker.js by running

makerjs.exporter.toSVGPathData(makerjs.model.simplify(makerjs.model.outline(makerjs.importer.fromSVGPathData(stroke), strokeWidth / 2)), {
  accuracy: 10e-9,
  fillRule: "nonzero",
  origin: [0, 0]
});

I would really appreciate it if someone knew how to do this or a JavaScript library that can do it.

1

There are 1 best solutions below

3
On

There is no exact way to calculate offset paths from given pathdata as explained here:
"Primer on Bézier Curves: §39 Curve offsetting".
That's why we need some tricks to get a visually appealing result.

Dedicated vector graphic apps provide the best results

Pretty much any vector editor (such as free/open-source Inkscape or commercial solutions like Adobe Illustrator, Affinity Designer, Corel Draw etc.) provides a way to convert strokes to paths.

So you should first check batch processing options like e.g Inkscape's cli or Adobe illustrator actions or user scripts to streamline this process.

The following examples highlight the command end points to illustrate the conversion result (less points and more symmetric distribution = better approximation).

<h3>inkscape (path/stroke to path)</h3>
<style>
svg {
  overflow: visible;
}

path {
  marker-start: url(#markerStart);
  marker-mid: url(#markerRound);
  stroke-width: 0.5% !important;
}
</style>


<svg id="svg" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg" >
  <path d="M1 0a1 1 0 0 0-1 1c0 1.7 0.8 3.3 2.3 4.7c0.3 0.3 0.7 0.6 1 0.8c-0.8 0.5-1.7 1.1-2.7 1.6a1 1 0 0 0-0.5 1.3a1 1 0 0 0 1.3 0.5c1.4-0.7 2.6-1.4 3.6-2c1 0.6 2.2 1.3 3.6 2a1 1 0 0 0 1.3-0.5a1 1 0 0 0-0.5-1.3c-1-0.5-1.9-1.1-2.7-1.6c0.3-0.2 0.7-0.5 1-0.8c1.5-1.4 2.3-3 2.3-4.7a1 1 0 0 0-1-1a1 1 0 0 0-1 1c0 1-0.5 2.1-1.7 3.3c-0.3 0.3-0.9 0.7-1.3 1c-0.4-0.3-1-0.7-1.3-1c-1.2-1.2-1.7-2.3-1.7-3.3a1 1 0 0 0-1-1z"/>
</svg>

<!-- markers to show commands -->
<svg id="svgMarkers" style="width:0; height:0; position:absolute; z-index:-1;float:left;">
  <defs>
    <marker id="markerStart" overflow="visible" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="strokeWidth" markerWidth="10" markerHeight="10" orient="auto-start-reverse">
      <circle cx="5" cy="5" r="5" fill="green"></circle>
      <marker id="markerRound" overflow="visible" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="strokeWidth" markerWidth="10" markerHeight="10" orient="auto-start-reverse">
        <circle cx="5" cy="5" r="2.5" fill="red"></circle>
      </marker>
  </defs>
</svg>

JS approach: maker.js

Actually not bad at all but still not on par with aforementioned graphic apps. The merging of separate sup paths should also work just fine.
However, maker.js can't reproduce all fine-grained options like stroke-linejoin or specific meter-limits. Worth noting you can at least emulate stroke-linecap with 3 options using the expandPaths() method

let svg = document.querySelector("svg");
let path = svg.querySelector("path");

convertStroke(path)

function convertStroke(path) {
  
  // get path atts
  let pathData = path.getAttribute("d");
  let strokeWidth = +path.getAttribute("stroke-width");
  let attlinecap = path.getAttribute("stroke-linecap");
  let strokelinejoin = attlinecap==='round' ? 0 : (attlinecap==='square' ? 2 : 1);  
  
  // init maker
  var m = require("makerjs");
  let curve1 = new m.importer.fromSVGPathData(pathData);
  
  // expand paths by stroke
  let expanded = m.model.expandPaths(curve1, strokeWidth/2, strokelinejoin);

  // define model
  let model = {
    models: {
      outline: expanded
    }
  };


  // generate svg out
  let svgOut = m.exporter.toSVG(model, { useSvgPathOnly: true });
  let doc = new DOMParser().parseFromString(svgOut, 'text/html').querySelector('svg')
  let pathNew = doc.querySelector('path')
  let d= pathNew.getAttribute('d')
  
  // update outline path
  path.setAttribute('d', d)
  path.removeAttribute('stroke')
  path.removeAttribute('stroke-width')
  path.removeAttribute('fill')
  path.classList.add('strokeToPath')
  
 let markup = new XMLSerializer().serializeToString(svg);
 svgMarkup.value = markup;
}
svg {
  overflow: visible;
}

.strokeToPath {
  marker-start: url(#markerStart);
  marker-mid: url(#markerRound);
  stroke-width: 0.5% !important;
}


textarea{
  display:block;
  width:100%;
  min-height: 20em;
}
<script src="https://cdn.jsdelivr.net/npm/makerjs@0/target/js/browser.maker.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bezier-js@2/bezier.js"></script>

<h3>Maker.js</h3>
<svg xmlns="http://www.w3.org/2000/svg" id="svg" class="svgPreview" viewBox="0 0 10 10">
  <path d="M1 1Q1 5 9 9M9 1Q9 5 1 9" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" />
</svg>

<h3>SVG output</h3>

<textarea id="svgMarkup" ></textarea>

<!-- markers to show commands -->
<svg id="svgMarkers" style="width:0; height:0; position:absolute; z-index:-1;float:left;">
  <defs>
    <marker id="markerStart" overflow="visible" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="strokeWidth" markerWidth="10" markerHeight="10" orient="auto-start-reverse">
      <circle cx="5" cy="5" r="5" fill="green"></circle>
      <marker id="markerRound" overflow="visible" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="strokeWidth" markerWidth="10" markerHeight="10" orient="auto-start-reverse">
        <circle cx="5" cy="5" r="2.5" fill="red"></circle>
      </marker>
  </defs>
</svg>

Make sure you also load bezier.js that does the heavy lifting when calculating/approximating offset paths.

<script src="https://cdn.jsdelivr.net/npm/bezier-js@2/bezier.js"></script>

Converter web app services

iconly also provides an automated service to convert stroked graphics to outlines. "iconly: Convert SVG Strokes to Fills"

svg {
  overflow: visible;
}

path {
  marker-start: url(#markerStart);
  marker-mid: url(#markerRound);
  stroke-width: 0.5% !important;
}
<h3>Iconly</h3>

<svg xmlns="http://www.w3.org/2000/svg" class="svgPreview" viewBox="0 0 10 10">
<path d="M.78.025a.944.944 0 0 0-.488.282C.016.585-.042.847.026 1.496.157 2.744.715 3.92 1.744 5.117a13.8 13.8 0 0 0 1.319 1.299c.112.096.203.179.203.183 0 .011-.058.046-.474.292-.602.356-1.208.688-1.914 1.05-.438.226-.532.286-.643.415a1.037 1.037 0 0 0-.209.869c.121.465.514.775.981.775.186 0 .29-.036.701-.241 1.035-.515 2.416-1.305 3.171-1.812L5 7.866l.121.081c.755.507 2.136 1.297 3.171 1.812.411.205.515.241.701.241.527 0 .971-.414 1.003-.935a1.003 1.003 0 0 0-.381-.851 6.485 6.485 0 0 0-.493-.273 35.463 35.463 0 0 1-1.914-1.05c-.416-.246-.474-.281-.474-.292 0-.004.091-.087.203-.183a13.8 13.8 0 0 0 1.319-1.299C9.285 3.92 9.843 2.744 9.974 1.496c.068-.648.01-.91-.266-1.19a.924.924 0 0 0-.562-.292.95.95 0 0 0-.854.293c-.216.218-.269.356-.293.751a5.531 5.531 0 0 1-.042.425c-.19 1.19-1.104 2.45-2.712 3.74L5 5.419l-.245-.196C3.279 4.039 2.382 2.873 2.101 1.77a3.725 3.725 0 0 1-.1-.714c-.023-.392-.078-.531-.293-.75a.785.785 0 0 0-.275-.202A.985.985 0 0 0 .78.025" 
/>
</svg>

<!-- markers to show commands -->
<svg id="svgMarkers" style="width:0; height:0; position:absolute; z-index:-1;float:left;">
  <defs>
    <marker id="markerStart" overflow="visible" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="strokeWidth" markerWidth="10" markerHeight="10" orient="auto-start-reverse">
      <circle cx="5" cy="5" r="5" fill="green"></circle>
      <marker id="markerRound" overflow="visible" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="strokeWidth" markerWidth="10" markerHeight="10" orient="auto-start-reverse">
        <circle cx="5" cy="5" r="2.5" fill="red"></circle>
      </marker>
  </defs>
</svg>

As you can see, the result introduces a lot of additional commands. However it still rebuilds the original curves quite accurately.
AFAIK this conversion is based on a tracing concept (using potrace).

JS: retrace svg via potrace.js

Although the idea of rasterizing a vector graphic and then tracing it back to vector image feels terribly wrong ... we can in fact bypass a lot of challenges such as converting advanced stroke-linejoin or miter-limit settings that are otherwise difficult to emulate.
Besides we can make sure we have a super-clean bitmap source for tracing.

let svg = document.querySelector("svg");
let path = svg.querySelector("path");

(async() => {
  convertStrokePotrace(path, scale = 1)
})();

async function convertStrokePotrace(path, scale = 1) {
  let dataUrl = await svg2PngDataUrl(svg, scale);
  let tracingOptions = {
    turnpolicy: "majority",
    turdsize: 1,
    optcurve: true,
    alphamax: 1,
    opttolerance: 1
  };

  let {
    width,
    height
  } = svg.viewBox.baseVal.width ? svg.viewBox.baseVal : {
    width: svg.width.baseVal.value,
    height: svg.height.baseVal.value
  };
  
  
      // set parameters
    Potrace.setParameter(tracingOptions);
    Potrace.loadImageFromUrl(dataUrl);
    Potrace.process(function() {
      // scale back
      let {
        renderedWidth,
        renderedHeight
      } = {
        renderedWidth: Potrace.img.width,
        renderedHeight: Potrace.img.height
      }
      scale = renderedWidth ? renderedWidth / width : 1
      // get pathData
      let tracedSVG = Potrace.getSVG(1 / scale);
      let tracedPath = new DOMParser().parseFromString(tracedSVG, 'text/html').querySelector('path')
      
      // replace pathdata
      let dTraced = tracedPath.getAttribute('d')
      path.setAttribute('d', dTraced)
      path.setAttribute('stroke', 'none')
      path.removeAttribute('stroke-width')
      path.removeAttribute('stroke-linecap')
      path.setAttribute('fill', '#000')
    });
  
}


/**
 * svg to canvas
 */
async function svg2PngDataUrl(el, scale = 1, filter = "") {
  /**
   *  clone svg to add width and height
   * for better compatibility
   * without affecting the original svg
   */
  const svgEl = el.cloneNode(true);
  
  document.body.append(svgEl)
  
  // get dimensions
  let {
    width,
    height
  } = el.getBBox();
  let w = el.viewBox.baseVal.width ?
    svgEl.viewBox.baseVal.width :
    el.width.baseVal.value ?
    el.width.baseVal.value :
    width;
  let h = el.viewBox.baseVal.height ?
    svgEl.viewBox.baseVal.height :
    el.height.baseVal.value ?
    el.height.baseVal.value :
    height;


  // autoscale for better tracing results
  let sidelength = Math.min(w, h)
  let scaledW = 1000
  scale = scaledW / sidelength > 1 ? scaledW / sidelength : 1;


  // apply scaling
  [w, h] = [w * scale, h * scale];
  // add width and height for firefox compatibility
  svgEl.setAttribute("width", w);
  svgEl.setAttribute("height", h);
  // create canvas
  let canvas = document.createElement("canvas");
  canvas.width = w;
  canvas.height = h;
  // create blob
  let svgString = new XMLSerializer().serializeToString(svgEl);
  let blob = new Blob([svgString], {
    type: "image/svg+xml"
  });
  let blobURL = URL.createObjectURL(blob);
  let tmpImg = new Image();
  tmpImg.src = blobURL;
  tmpImg.width = w;
  tmpImg.height = h;
  tmpImg.crossOrigin = "anonymous";
  await tmpImg.decode();
  let ctx = canvas.getContext("2d");
  ctx.fillStyle = "white";
  ctx.fillRect(0, 0, w, h);
  // apply filter to enhance contrast
  if (filter) {
    ctx.filter = filter;
  }
  ctx.drawImage(tmpImg, 0, 0, w, h);
  //create img data URL
  let dataUrl = canvas.toDataURL();
  
   // remove clone
  svgEl.remove()
  return dataUrl;
}
svg{
overflow:visible
}

path {
  marker-start: url(#markerStart);
  marker-mid: url(#markerRound);
  stroke-width: 0.5% !important;
}
<script src="https://cdn.jsdelivr.net/gh/kilobtye/potrace@master/potrace.js"></script>

<h3>potrace.js</h3>
<svg id="svg" class="svgPreview" viewBox="0 0  10 10">
    <path d="M1 1Q1 5 9 9M9 1Q9 5 1 9" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" />
  </svg>

<!-- markers to show commands -->
<svg id="svgMarkers" style="width:0; height:0; position:absolute; z-index:-1;float:left;">
  <defs>
    <marker id="markerStart" overflow="visible" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="strokeWidth" markerWidth="10" markerHeight="10" orient="auto-start-reverse">
      <circle cx="5" cy="5" r="5" fill="green"></circle>

      <marker id="markerRound" overflow="visible" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="strokeWidth" markerWidth="10" markerHeight="10" orient="auto-start-reverse">
        <circle cx="5" cy="5" r="2.5" fill="red"></circle>
      </marker>
  </defs>
</svg>

See this working example on codepen.

JS: convert via paper.js with paperjs-offset extension

paperjs-offset is an extension for paper.js allowing you to calculate offset paths.
The results are also quite clean. Depending on the curvature you might also sometimes return undesired results.

// setup paper.js
const canvas = document.createElement("canvas");
canvas.style.display='none';
document.body.appendChild(canvas);
paper.setup(canvas);


let strokeWidth = +path1.getAttribute('stroke-width')/2;
let strokeLinecap = path1.getAttribute('stroke-linecap');
let path1Obj = new paper.CompoundPath(path1.getAttribute("d"));
let offsetPathObj = PaperOffset.offsetStroke(path1Obj, strokeWidth, { cap: strokeLinecap } )

// get pathdata string
let dOffset = offsetPathObj.exportSVG().getAttribute("d");
path1.setAttribute("d", dOffset)
svg{
overflow:visible
}

path {
  marker-start: url(#markerStart);
  marker-mid: url(#markerRound);
  stroke-width: 0.5% !important;
}
<script src="https://unpkg.com/[email protected]/dist/paper-full.min.js"></script>
<script src="https://unpkg.com/[email protected]/dist/paperjs-offset.js"></script>

<h3>paper.js with paperjs-offset</h3>
<svg id="svg" class="svgPreview" viewBox="0 0  10 10">
    <path id="path1" d="M1 1Q1 5 9 9 M9 1Q9 5 1 9" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" />
    <path id="path2" d=" " fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" />
  </svg>

<!-- markers to show commands -->
<svg id="svgMarkers" style="width:0; height:0; position:absolute; z-index:-1;float:left;">
  <defs>
    <marker id="markerStart" overflow="visible" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="strokeWidth" markerWidth="10" markerHeight="10" orient="auto-start-reverse">
      <circle cx="5" cy="5" r="5" fill="green"></circle>

      <marker id="markerRound" overflow="visible" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="strokeWidth" markerWidth="10" markerHeight="10" orient="auto-start-reverse">
        <circle cx="5" cy="5" r="2.5" fill="red"></circle>
      </marker>
  </defs>
</svg>