A literal edge case

The other day, I was in an <svg>, trying to make the end of a line into a triangle, like that of a pencil.

I eventually realized that the most elegant way to do it was with a marker definition like this:

<svg width="100%" height="50">
  <title>Line with triangular point diagram</title>
  <defs>
    <marker
      id="pencil-point"
      viewBox="0 0 10 10"
      refX="0"
      refY="5"
      markerWidth="1"
      markerHeight="1"
      orient="auto-start-reverse"
    >
      <path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke" />
    </marker>
  </defs>
  <line
    x1="40"
    x2="200"
    y1="25"
    y2="25"
    stroke="hsl(35, 80%, 60%)"
    stroke-width="20"
    marker-start="url(#pencil-point)"
  ></line>
</svg>
Line with triangular point diagram

If you just wanted to know how to cut a triangle into the end of a line efficiently, there’s no need to read further. Close the tab immediately.

The rest of this post is about a <clipPath> mystery that I encountered back when I was trying to do this the wrong way.


clipPaths

In my early attempts to cut a triangle into the line, I tried applying a clipPath to a line.

clipPaths work like this:
  • Inside the <clipPath> element, you define a shape.
  • You apply the <clipPath> to a target element.
  • The parts of the target that fall inside* the shape you defined will show up, but the parts outside of the shape will disappear.
*The definition of “inside” played a big part of this mystery.

Pre-clip baseline

In order to make sure I started this attempt on firm footing, I began with no <clipPath> at all:

<svg width="100%" height="50">
<title>Horizontal line with no clipPath</title>
<line
  x1="40"
  x2="200"
  y1="25"
  y2="25"
  stroke="hsl(35, 80%, 60%)"
  stroke-width="20"
></line>
</svg>
Horizontal line with no clipPath

So far, so good. I got what I expected: a thick horizontal line, with nothing clipped.

Unsuccessful clip

Next, I added a triangle clip.

<svg width="100%" height="50">
  <title>A triangle clipPath</title>
  <defs>
    <clipPath id="tclip" clipPathUnits="objectBoundingBox">
      <polygon points="0,0.5 1,1 1,0"></polygon>
    </clipPath>
  </defs>
  <line
    x1="40"
    x2="200"
    y1="25"
    y2="25"
    stroke="hsl(35, 80%, 60%)"
    stroke-width="20"
    clip-path="url(#tclip)"
  ></line>
</svg>
A triangle clipPath

And everything disappeared.

Debugging with the all-inclusive clipPath

I didn’t know why this was happening, so I backed up and tried the simplest <clipPath> I could think of: a rectangle that covers the entire target element. Such a <clipPath>, I thought, should show everything (“clip everything in”) and hide nothing (“clip nothing out”).

<svg width="100%" height="50">
  <title>A clipPath that I thought would show everything but shows nothing</title>
  <defs>
    <clipPath id="hclip" clipPathUnits="objectBoundingBox">
      <rect x="0" y="0" width="1" height="1"></rect>
    </clipPath>
  </defs>
  <line
    x1="40"
    x2="200"
    y1="25"
    y2="25"
    stroke="hsl(35, 80%, 60%)"
    stroke-width="20"
    clip-path="url(#hclip)"
  ></line>
</svg>
A clipPath that I thought would show everything but shows nothing

And yet everything was clipped out.

Did the horizontalness of the line somehow cause this? To rule it out, I tried out an almost horizontal line like this:

<svg width="100%" height="50">
  <title>The almost-horizontal line with clipPath</title>
  <defs>
    <clipPath id="ahclip" clipPathUnits="objectBoundingBox">
      <rect x="0" y="0" width="1" height="1"></rect>
    </clipPath>
  </defs>
  <line
    x1="40"
    x2="200"
    y1="25"
    y2="26"
    stroke="hsl(35, 80%, 60%)"
    stroke-width="100%"
    clip-path="url(#ahclip)"
  ></line>
</svg>
The almost-horizontal line with clipPath

A line showed up.

Both the perfectly horizontal line and the almost horizontal line had the same “show everything” clipPath applied to them. So why does the almost horizontal one show up, but the perfectly horizontal one not show up?

Here is what I think is a likely explanation.

The first thing to notice is that I’m using objectBoundingBox as the clipPathUnits attribute value.

    <clipPath id="ahclip" clipPathUnits="objectBoundingBox">
      <rect x="0" y="0" width="1" height="1"></rect>
    </clipPath>

As a result of the objectBoundingBox setting, when I say the x of the <rect> in the clipPath is 0, the rect’s left will be the very left of the element it’s being applied to. If I set the width to 1, that means that the rect will be the width of the entire element that the clipPath is being applied to.

It’s easier to understand in a diagram:

Bounding box and clip target relationship diagram clipPath target 70, 50 145, 50 70, 150 145, 150 clipPath 0, 0 1, 0 0, 1 1, 1

The clipPath’s whole world is between 0 and 1, and that world gets mapped onto the bounds of the element to be clipped, which is the <line> in this case.

The <rect> I put inside of the clip path goes from (0, 0) to (1, 1), which should cover everything.

However, in this case, everything is nothing.

The element I’m applying the clipPath to is a line. The line has a stroke-width of 20, so it looks like a thin rectangle. But clipPath does not use the stroke-width when deriving its objectBoundingBox; it only uses the path itself (the “raw geometry”, as the W3C spec calls it).

I think that the horizontal line, without decorative properties like stroke-width applied, is considered to have a height of 0.

Could it be that the line has a height of 1 (not 0) with an exclusive intersection algorithm?

Before, I thought it would have a height of 1. DevTools says the height of a horizontal line with no explicit stroke-width is 1. We know that <clipPath> doesn’t see it that way because of this bit from the spec (emphasis added):
The raw geometry of each child element exclusive of rendering properties such as fill, stroke, stroke-width within a clipPath conceptually defines a 1-bit mask (with the possible exception of anti-aliasing along the edge of the geometry) which represents the silhouette of the graphics associated with that element. Anything outside the outline of the object is masked out.

If the line had a height of 1, then there would exist a 1-unit high part of the line that intersected the horizontal line mask. This would NOT be outside of the mask, and it would show up in the end. Since nothing shows up, the height of the horizontal line mask must be 0, and everything is considered to be outside the mask.

Another possibility is that Firefox and Chrome didn’t implement things that way and instead implemented clipPaths that only allowed things inside the boundaries of the clipPath to show. In that case, nothing can be inside of a 1-unit high “box.” (I tried to disprove this by looking at the source, but at some point, I stopped being able to tell what implementations implemented which interfaces.)

Regardless of the way the browsers implement <clipPath>, this behavior is consistent: putting a slope on the line gives the bounding box a height greater than 0.

To see this easily, let’s exaggerate the slope. Here, we set the angle of the line to 45° and make the stroke-width ridiculously wide.

<svg width="100%" height="200">
  <title>The diagonal line that is really thick</title>
  <defs>
    <clipPath id="dclip" clipPathUnits="objectBoundingBox">
      <rect x="0" y="0" width="1" height="1"></rect>
    </clipPath>
  </defs>
  <line
    x1="40"
    x2="200"
    y1="15"
    y2="175"
    stroke="hsl(35, 80%, 60%)"
    stroke-width="100%"
    clip-path="url(#dclip)"
  ></line>
</svg>
The diagonal line that is really thick

Now the bounding box becomes a squarish box because that is what is necessary to enclose a line with a 45° angle.

Was this completely useless?

This was not a short journey! But it provided us with these facts:

  • <clipPath>s use the “raw geometry” of the elements it is applied to to determine the bounding box used by the elements within the clipPath.
  • Horizontal <line>s are considered to have a height of 0 when they are <clipPath> targets.
  • “Sloped” lines (lines with that aren’t vertical or horizontal) get a bounding rectangle that covers their height and width, rather than a bounding container shaped like a line.

I tried my best, but no doubt this is still confusing to some readers. Maybe it would have been better as a video. But I’m happy to chat if you need clarification! jimkang@fastmail.com.