Home > front end >  How to simulate stroke-align (stroke-alignment) in SVG
How to simulate stroke-align (stroke-alignment) in SVG

Time:01-02

I am trying to mimic the behavior of "stroke alignment" in an SVG object. While there is a working draft for stroke-alignment in the spec, this has not actually been implemented (despite being drafted in 2015).

Example of non-working stroke-alignment:

The blue square should have stroke inside, the red outside, but they're both the same
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="100">
  <g id="myGroup" transform="translate(20 20)">
    <polygon id="myPoly0" points="0,0 50,0 50,50 0,50" style="fill:blue;stroke:black;stroke-width:4;stroke-alignment:inner"></polygon>
    <polygon id="myPoly1" transform="translate(75 0)" points="0,0 50,0 50,50 0,50" style="fill:red;stroke:black;stroke-width:4;stroke-alignment:outer"></polygon>
  </g>
</svg>

My approach to mimicking this behavior is to create a duplicate SVG object using the <use> element, setting a stroke property on the copy and scaling it slightly up or down depending on whether it's an inner or outer stroke alignment (default is center)

For example:

The scale and transform for the &lt;use&gt; element gets worse the farther from origin

<svg xmlns="http://www.w3.org/2000/svg" width="500" height="400">
  <g id="myGroup" style="fill:rgb(45, 130, 255);" transform="translate(20 20)">
    <polygon id="myPoly0" points="0,0 100,0 100,100 0,100"></polygon>
    <polygon id="myPoly1" transform="translate(110 110)" points="0,0 100,0 100,100 0,100"></polygon>
    <polygon id="myPoly2" transform="translate(220 220)" points="0,0 100,0 100,100 0,100"></polygon>

    <use id="myPolyCopy0" vector-effect="non-scaling-stroke" href="#myPoly0" style="fill:none;stroke:black;stroke-width:4;" transform="translate(-2 -2) scale(1.04 1.04)"></use>
    <use id="myPolyCopy1" vector-effect="non-scaling-stroke" href="#myPoly1" style="fill:none;stroke:black;stroke-width:4;" transform="translate(-2 -2) scale(1.04 1.04)"></use>
    <use id="myPolyCopy2" vector-effect="non-scaling-stroke" href="#myPoly2" style="fill:none;stroke:black;stroke-width:4;" transform="translate(-2 -2) scale(1.04 1.04)"></use>
  </g>
</svg>

As you can see from the above example, the relative positioning of the <use> element goes awry, and gets worse the farther away from the origin it gets.

Naively, I assume that the transform property of the <use> element acts upon the SVG shape referenced in its href, but that seems not to be the case.

In my example, I'm scaling a 100x100 square by a factor of 1.04, which should result in a 104x104 square (4px width of the stroke). I'm then translating back by -2px to position the stroke on the outside of the source shape, thereby mimicking an outer stroke alignment.

This works as expected if the source shape is at origin (relative to the container group), but goes bonkers if the source shape is translated away from the origin.

My brain says this should work, but my browser says no bueno.

Anyone got any clues?

CodePudding user response:

So, it turns out that the transform applied to the <use> element will be applied to the existing transform of the source element.

This means that the scale transform applied to the <use> element will also scale its translate matrix.

For example:

If the source element has translate(100 100), applying a scale(1.1 1.1) on the <use> copy will cause it to have a translate with the value (110,110)

This means to move the copy with the stroke value back to the correct location, you need to move the copy far enough back to overcome this "scaled translation".

This may well be expected behavior, but it was not intuitive to me (I may need to RTFM). Overall this approach "works", but feels complicated and hacky.

Working sample:

const strokeWidth = 8;

const poly1Translate = {
  x: 150,
  y: 20
};

const poly2Translate = {
  x: 300,
  y: 40
};

const poly1 = document.getElementById("myPoly1");
const poly2 = document.getElementById("myPoly2");

const polyCopy0 = document.getElementById("myPolyCopy0");
const polyCopy1 = document.getElementById("myPolyCopy1");
const polyCopy2 = document.getElementById("myPolyCopy2");

const styleString = `fill:none;stroke:red;stroke-opacity:0.5;stroke-width:${strokeWidth};`;

poly1.setAttribute(
  "transform",
  `translate(${poly1Translate.x} ${poly1Translate.y})`
);
poly2.setAttribute(
  "transform",
  `translate(${poly2Translate.x} ${poly2Translate.y})`
);

polyCopy0.setAttribute("style", styleString);
polyCopy1.setAttribute("style", styleString);
polyCopy2.setAttribute("style", styleString);

// Use the boundingbox to get the dimensions
const poly1BBox = poly1.getBBox();
const poly2BBox = poly2.getBBox();

let halfStrokeWidth = strokeWidth / 2;

// stroke-alignment:outside

// Scale the copy to be strokeWidth pixels larger
let scaleOutsideX = 1 strokeWidth/poly1BBox.width;
let scaleOutsideY = 1 strokeWidth/poly1BBox.height;

// Move the copy to the same scale property based on the current translation
// This will position the stroke at the correct origin point, and we need to 
// deduct a further half of the stroke width to position it fully on the outside
let translateOutsideX = -((poly1Translate.x * scaleOutsideX - poly1Translate.x)   halfStrokeWidth);
let translateOutsideY = -((poly1Translate.y * scaleOutsideY - poly1Translate.y)   halfStrokeWidth);

polyCopy1.setAttribute('transform', `translate(${translateOutsideX} ${translateOutsideY}) scale(${scaleOutsideX} ${scaleOutsideY})`);

// stroke-alignment:inside
let scaleInsideX = 1-strokeWidth/poly2BBox.width;
let scaleInsideY = 1-strokeWidth/poly2BBox.height;
let translateInsideX = poly2Translate.x * scaleOutsideX - poly2Translate.x   halfStrokeWidth;
let translateInsideY = poly2Translate.y * scaleOutsideY - poly2Translate.y   halfStrokeWidth;

polyCopy2.setAttribute('transform', `translate(${translateInsideX} ${translateInsideY}) scale(${scaleInsideX} ${scaleInsideY})`);
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="160">
  <g id="myGroup" style="fill:rgb(45, 130, 255);" transform="translate(20 20)">
    <polygon id="myPoly0" points="0,0 100,0 100,100 0,100"></polygon>
    <polygon id="myPoly1" points="0,0 100,0 100,100 0,100"></polygon>
    <polygon id="myPoly2" points="0,0 100,0 100,100 0,100"></polygon>

    <use id="myPolyCopy0" vector-effect="non-scaling-stroke" href="#myPoly0"></use>
    <use id="myPolyCopy1" vector-effect="non-scaling-stroke" href="#myPoly1"></use>
    <use id="myPolyCopy2" vector-effect="non-scaling-stroke" href="#myPoly2"></use>
  </g>
</svg>

UPDATE

After noticing the following comment in the Figma website:

Inside and outside stroke are actually implemented by doubling the stroke weight and masking the stroke by the fill. This means inside-aligned stroke will never draw strokes outside the fill and outside-aligned stroke will never draw strokes inside the fill.

I implemented a similar method using a combination of <clipPath> and <mask>

.stroke {
  fill:none;
  stroke:red;
  stroke-opacity:0.5;
}  

.stroke-center {
  stroke-width:8;
}

.stroke-inout {
  stroke-width:16;
}
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="160">
  <defs>
    <rect id="stroke-mask" width="500" height="160" fill="white"/>
  </defs>
  <g id="myGroup" style="fill:rgb(45, 130, 255);" transform="translate(20,20)">
    <polygon id="myPoly0" points="0,0 100,0 100,100 0,100" transform="translate(0,20)"></polygon>
    <polygon id="myPoly1" points="0,0 100,0 100,100 0,100" transform="translate(150,20)"></polygon>
    <polygon id="myPoly2" points="0,0 100,0 100,100 0,100" transform="translate(300,20)"></polygon>
    <mask id="mask">
      <use href="#stroke-mask"/>
      <use href="#myPoly1" fill="black"/>
    </mask>       
    
    <clipPath id="clip">
      <use href="#myPoly2"/>
    </clipPath>    
    
    <use id="myPolyCopy0"  href="#myPoly0"></use>
    <use id="myPolyCopy1"  href="#myPoly1" mask="url(#mask)"></use>
    <use id="myPolyCopy2"  href="#myPoly2" clip-path="url(#clip)"></use>    
  </g>
</svg>

The idea here is, to achieve the equivalent of:

  1. stroke-align:center: is the default behavior, do nothing
  2. stroke-align:inner: Create a clipPath using the source object to which you want to apply the inner stroke, then with the <use> element, create a copy of this with a stroke twice the width you actually want, and set the clip-path of the copy to be the clipPath created from the source object. This will effectively clip everything outside the clipPath, thereby clipping the "outside half" of the double-width stroke
  3. stroke-align:outer: There isn't an equivalent of clipPath which will clip everything inside the path (sadly), so the way to achieve this is to use a <mask>, but the same principle applies as for inner. Create a <mask> based on the source object, create a copy with a double-width stroke, then use the mask to clip everything inside the mask, thereby clipping the "inside half" of the double-width stroke
  •  Tags:  
  • svg
  • Related