April 27, 2020
Moderate between CSS and SVG for the path microformat with library pathfit
In the last few years a lot of efforts have been made to dovetail CSS and SVG. There is even a resolution in the SVG 2 spec not to add any more presentation attributes, prefering instead pure CSS properties. Nonetheless, some parts resist their harmonization with a vigor. Path microsyntax is one of them.
(While this article is really about introducing a library, its first part
is a general essay about parsers. Whoever is not interested, can find
out further down how pathfit
scales path descriptions to
container elements.)
In CSS we are used to positions and measurements having a unit that is appended to the number. In contrast, SVG has the concept of a user space coordinate system that can be transformed and re-defined at any place within the XML tree. The coordinates themselves thus can remain unitless.
Both concepts have drawn grooves deep into their language grammar and lead to basic concepts that are no longer easy to align.
In addition, there is a difference in the manner how words are separated. The SVG approach is more permissive in nature, leaving commas to be generally optional. Even whitespace can be left off, as long as adjecent words can be uniquely discerned. CSS differentiates between the meaning of spaces (separating keywords) and commas (outlining lists).
🔗 Why have paths no units?
The micro syntac of the path definition attribute (<path d="..." />
),
having originated in SVG, by now founds its way into CSS: besides referencing
<path>
elements by their URL, for example in properties like shape-outside
,
there are some properties that accept the path()
function as their
value, using the path syntax for a string argument: clip-path
,
motion-path
, shape-inside
and shape-outside
.
Now that browsers implement these properties, developers are complaining ever
more often that dimensionless paths de facto only represent px
units,
which will not act responsively. “We want percentage values!” is the
outcry I read again and again.
But I can see no viable way to realise that demand. Neither can the CSS parser handle the path grammar, nor is the SVG parser designed to include numbers with units. Downwards compatible changes have severe challenges to master.
🔗 Paths in the CSS parser
Let's have a look at a path definition attribute. All the following string variants represent the same AST, and all follow a typical pattern that some common software uses for serialization:
M0,20h20q5-10,20-10,5,10,2,20z <- Illustrator
M 0,20 h 20 q 5,-10 20,-10 5,10 20,20 z <- Inkscape
M 0 20 h 20 q 5 -10 20 -10 5 10 20 20 z <- D3
In YAML, the tree could maybe look as follows:
- command: M
sequence:
- coordinate_pair: { x: 0, y: 20 }
- command: h
sequence:
- coordinate: 20
- command: q
sequence:
- control_point: { x: 5, y: -10 }
coordinate_pair: { x: 20, y: -10 }
- control_point: { x: 5, y: 10 }
coordinate_pair: { x: 20, y: 20 }
- command: z
The same sequences, if interpreted as CSS, would lead to three different results. The first one would be already unusable after tokenization :
- token: name
value: M0
- token: comma
- token: dimension
value: 20
type: integer
ident: h20q5-10 # see https://www.w3.org/TR/css-values/#component-whitespace
- token: comma
- token: number
value: -10
type: integer
- token: comma
# etc
For the second variant, commas would be interpreted as a higher level structure, leaving the result as a nested list that rips the coordinates apart:
- [M, 0]
- [20, h, 20, q, 5]
- [-10, 20]
- [-10, 5]
- [10, 20]
- [20, z]
Only the third variant would lead to a result (a simple list of names and numbers) that left the parser with a chance to interpret the grammar correctly.
To summarize: The CSS parser cannot interpret path commands reliably. Processing of the path microsyntax must be done by the corresponding SVG parser component. That is the reason why the path argument is escaped in quotes, as a precondition to handing it on unaltered.
🔗 Units in the SVG parser
SVG in version 2 describes attribute values almost always by linking to CSS Values and Units . Also most length values are specified this way. The one notable exception are path definitions, which have their own EBNF grammar . The obvious reason is, the mix of commands and numbers is just as unfit for CSS treatment as the permissive handling of whitespace and commas.
Would it be possible to enhance the parser described like this to also understand length units? There are some stumbling blocks.
The following sequence is a mix of numbers and letters:
M1e2, 3l4.5in, 6Q 7,8 9,10
The first M
is a path command. The e
is part of a number written in
scientific notation, the l
is a path command again, and the letters
in
represent a length unit. But what about the Q
?
As a path command, it represents a quadratic Bezier curve, which is
parametrized by the four following numbers (two absolute coordinates). But
there also is a length unit Q
, measuring a quarter-millimeter. Honestly,
I didn't know about that before finding it in the
spec , but it exists and
a short test showed it is implemented by browsers.
A parser that aims at being downward compatible cannot abandon its basic concepts and must maintain its “greedy” mode of operation:
The processing of the EBNF must consume as much of a given EBNF production as possible, stopping at the point when a character is encountered which no longer satisfies the production.
Which means: if a number can be followed by a unit, then first everything following the number is tried to be interpreted as a unit. Only after that the consumation of the production “length” is complete, and the next unit can be understood as a command.
Thus the Q
would be coerced to be a unit associated with the preceding number —
leaving downward compatibility impossible.
An interesting variant was proposed by Amelia Bellamy-Royds in 2016: Wrap all numbers that have units, or even computations, in round brackets. Like this (her example):
M(10px)(1em)
H(100%-10px)
A(10px)(5mm)0 0 0 (100%)(1em+5mm)
V(1em+3in-5mm)
A(10px)(5mm)0 0 0(100%-10px)(1em+3in)
H(10px)
A(10px)(5mm)0 0 0(0px)(1em+3in-5mm)
V(1em+5mm)
A(10px)(5mm)0 0 0 Z
So far, this lead to nothing, although the proposition was added to the milestones for a future SVG version 2.1.
Computing with paths
That leads us to the replacement solution: if paths utilized in CSS should adapt to units, they have to be recomputed in advance to suit user space coordinates.
One use case is especially important here: relative units are able to responsively adapt. It is not that vital in this case that every individual number gets its own unit. There would be much to be gained if at least the path as a whole can be subjugated to transformations.
One could say: well, that is what the transform
CSS property is for. And
for clip-path
that is most probably enough.
But looking at shape-inside
and shape-outside
things get more involved.
The text flowing around the path gets also transformed. And for motion-path
the element to be animated would also be subject to the transformation. It
would be neccessary to define an extraneous inner element with an inverse
transformation to counteract this.
And this would still not achieve responsiveness, because relative
units do not work with all transformation functions. Especially the most
important one, scale()
, works only with dimensionless numbers. It
cannont react for example to changes in the size of
the viewport. How would you write down that the result of scaling should
be a percentage of a container element?
🔗 Size adaptation as a standard task
This is the problem my library pathfit
tackles. MY thanks got to Jhey Tompkins for
starting me
on this project.
At its heart it rewrites path definitions by applying arbitrary affine transforms to them. Like this:
source path: 'M 0,0 100,100'
transformation: translate(50, 0) scale(0.5, 0.8)
resulting path: 'M 50,0 100,80'
This, by the way, would have not required a new library, as it already exists . It is only that the functionality aimed at here, scaling a path responsively to its environment, needs some more steps of computation.
Therefore the library exposes two more methods of computation. If an original size is given for a path, i. e. a rectangle the path "fits in", the path can be scaled such that it will fit into another rectangle with different dimension just the same way. For example:
source path: 'M 0,0 100,0 100,100 0,100 z'
source size: width: 100px; height: 100px;
target size: width: 50px; height: 50px;
resulting path: 'M 0,0 50,0 50,50 0,50 z'
Everyone who has done this sort of fitting will note that it needs to be more involved than this. There is a problem when the aspect ratios of the source and target rectangles are not matching. Then, more information is needed to describe the mode and the position of the "fit".
There are two syntax variants for this:
-
SVG has a source
viewBox
with four parameters for position and size, and attributepreserveAspectRatio
for describing how to fit it to its target. -
CSS only knows about two values for an intrinsic with and height of the source, but then the properties
object-fit
(orbackground-size
) andobject-position
(orbackground-position
) constitute a richer syntax for describing the intended result.
pathfit
implements both variants.
With preserveAspectRatio
an application could look like this:
const pathfitter = new Pathfit({
viewBox: '0 0 100 100',
preserveAspectRatio: 'xMinYMid meet'
});
pathfitter.set_path('M 0,0 100,0 100,100 0,100 z');
const container = document.querySelector('.motionContainer');
const moving = container.querySelector('.moving');
const containerObserver = new ResizeObserver((entries) => {
const {width, height} = entries.pop().contentRect;
const scaled_path = pathfitter.scale_with_aspect_Ratio(width, height);
moving.style.offsetPath = `path('${scaled_path}')`;
});
containerObserver.observe(container);
With object-fit
I can present a complete and executable example:
See the Pen Responsive offset-path with pathfit.js by ccprog (@ccprog) on CodePen.
A small tip to wrap it up: Like all npm packages, pathfit
can be linked directly from
unpkg.com :
<script src="https://unpkg.com/pathfit/pathfit.js"></script>