Let's say we have a paragraph in our page, with a single block of text.
<p>laborum beatae est nihil, non hic ab, deserunt repellat quas. Est molestiae ipsum minus nesciunt tempore voluptate laboriosam</p>
DOM-wise, the structure is:
HTMLParagraphElement
Text [laborum beatae est nihil...]
Now we split it (with Text.splitText()
) twice, to separate "deserunt repellat quas. Est" fragment. The structure becomes:
HTMLParagraphElement
Text [laborum beatae est nihil...]
Text [deserunt repellat quas. Est]
Text [ molestiae ipsum minus nesciunt...]
While this operation affects DOM, it never changes it on the Element level (Text !== Element), so I expected no visual changes.
Yet splitText()
affects layout as well, triggering both relayout and repaint in all the tested browsers (Chrome 60, Firefox 55, Edge 14 - all on Windows 10 OS). The same happens when we call ParagraphElement.normalize()
, reducing the number of Text nodes back to 1; again both relayout and repaint are triggered.
There is a nasty side-effect of this, which can be seen in this demo. If you check the words near 'quas. Est', you see they actually change positions!
It's clearly visible in Firefox, and is far more subtle (yet also distinguishable) in Chrome. To my amusement, no such "word dance" occurred in Edge.
The reason why this is important is shown in this demo of (kind of) shimmed selection engine. This particular version won't work in Firefox (no support for caretRangeFromPoint
yet - argh!), but even with "point2dom" rewired onto caretPositionFromPoint
the highlighted text is repositioned there - as much in Chrome, or even worse. Again, it seems to work well in Edge.
So, in fact, I'm mostly interested in both understanding the reasons and finding the workarounds.
Here's the animated gif showing how the first demo plays in Chrome (I just trigger a click in interval)
The tremble IS subtle here, but still can be observed on all the words. I'm especially puzzled by why i
in molestiae
shakes, as surrounding letters seems to stay where they are.
And it gets worse (far worse) with less common fonts and more text, like in selection demo.
Switching to font-family:monospace
didn't solve this, but made it seemingly worse:
Switching font-kerning
to none
didn't help either.
UPDATE: The issue is registered on the Blink tracker.
The splitText() method of the Text interface breaks the Text node into two nodes at the specified offset, keeping both nodes in the tree as siblings. After the split, the current node contains all the content up to the specified offset point, and a newly created node of the same type contains the remaining text.
The splitText() method breaks the Text node into two nodes at the specified offset index, keeping both nodes in the tree as siblings. After Splitting the text, the main node contains all the content up to the specified offset index point, and a newly created node of the same type contains the remaining text.
About the relayout/repaint it is to be expected, as the text nodes are DOM nodes too... Not DOM elements, but the browsers do have to reconsider the layout even if you would expect it to stay the same, they may have to move. Maybe because of kerning.
Now, why splitting text causes some moving? What I'd expect is because the browsers draw the text parts separately. Two neighbouring letters usually have a space that may be reduced by the font depending on the letters, take "WA" for example, the end of the W is above the start of the A, that is called kerning (Thx Ismael Miguel). When text nodes are drawn separately, each one has to finish before the next starts, so it may create a bigger space between those letters as it prevents any kerning.
Sorry, the space between letters does have a name but I forgot...
.one {
background-color: #FF9999;
}
.two {
background-color: #99FF99;
}
body {
font-size: 40px;
}
div>span {
border: 1px solid black;
}
<div><span>AW</span> - in the same DOM node.</div>
<div><span><span>A</span><span>W</span></span> - in two different nodes</div>
<div><span><span class="one">A</span><span class="two">W</span></span> - in two different nodes, colored</div>
As to how to prevent this behaviour, the most easy solution is to use a monospace font. This might not always be aesthetically feasible. Kerning is an information embedded in the font files, stripping a font from this information seems to be the most robust way to prevent flicker from it. ALso, the CSS property font-kerning could help when set to none
.
Another way is to add absolute elements behind or in front of the text to mimic the fact of surrounding a part of text into an element, but that is all depending on the end goal.
Looking a bit further CSS-Tricks have a nice article about text-rendering and it could also help on font-kerning.
EDIT: When writing this answer, I had overlooked the fact that the text was justified. While my answer explains why a certain flicker could happen when cutting a text node into multiple ones, it by no mean explains why the browsers seem to have problems calculating the justified spaces.
splitText()
may be the action that is triggering the change, but the change
is actually being caused by the updated dom being run through the text
justification engine. Change from text-align: justify;
to
text-align: left;
to see what I mean.
Addendum
That handles the words shifting around. As to the letters shifting around when justify is turned off, this is a little harder to quantify, but it appears to be a rounding threshold that is being crossed.
Text justification is complicated to implement to say the least. To simplify, there are three basic considerations:
A gain in any one, requires a loss on one or both of the others. For example, InDesign, which favors aesthetics, uses both word spacing (positive and negative), letter spacing (positive and negative), and considering all lines in a paragraph to find the most pleasing layout at a cost of speed, and allows for optical margin alignment at the cost of accuracy. But because it only goes through all this once and caches the results in the file, it can get away with being (relatively) very slow.
Browsers tend to care a lot more about speed, partly because they need to be able to justify text quickly on outdated hardware, but also because thanks to the interactivity we now enjoy, it sometimes needs to re-justify the same block of text thousands of times during a session.
The spec is somewhat vague on the topic of justification, saying things like:
When justifying text, the user agent takes the remaining space between the ends of a line’s contents and the edges of its line box, and distributes that space throughout its contents so that the contents exactly fill the line box. The user agent may alternatively distribute negative space, putting more content on the line than would otherwise fit under normal spacing conditions.
And
For auto justification, this specification does not define what all of the justification opportunities are, how they are prioritized, or when and how multiple levels of justification opportunities interact.
As a result, every browser is free to optimize this functionality as they see fit.
A modern text justification engine is more complicated than can be reasonably explained in the space we have here. Add that they are continuously tweaked to find a better balance between the primary considerations and anything I write here will be out of date in a few nanoseconds anyway, I'm going to use a very old (and much simpler) text justification algorithm to demonstrate how an engine might have difficulty rendering consistently in this situation.
Suppose we have the string 'Lorem ipsum dolor sit amet, consectetur.'
and we
can fit 35 characters on a line. So let's use this justification algorithm:
Using this algorithm:
Split the string
'Lorem ipsum dolor sit amet, consectetur.' =>
['Lorem','ipsum','dolor','sit','amet,'] & 'consectetur.'
With 23 characters of text width and 35 characters of space available, we add 3 spaces between each word (I'm quadrupling the space for emphasis, which will be important later)
-------------------------------------------------------------------------
|Lorem ipsum dolor sit amet,|
|consectetur. |
-------------------------------------------------------------------------
This is fast because we can draw everything from left to right with no need to backtrack, and we don't need to look ahead.
If we run textSplit
on this and effectively turn it into an array:['Lorem ipsum dolor ','sit',' amet, consectetur.']
So we'd need to modify our rules, let's change rule 2 to work through each
string in the array, following the same rules as before.
Split the strings, note that there is a space before amet, so the word boundary won't catch it
`['Lorem ipsum dolor ','sit',' amet, consectetur.']` =>
['Lorem','ipsum','dolor','sit',' amet,'] & 'consectetur.'
With 24 characters of text width and 35 characters of space available, we add 2.75 spaces between each word (Again quadrupling the space). The extra space in the amet string is also drawn.
-------------------------------------------------------------------------
|Lorem ipsum dolor sit amet,|
|consectetur. |
-------------------------------------------------------------------------
If we look at the two lines side be side, we can see the difference.
-------------------------------------------------------------------------
a |Lorem ipsum dolor sit amet,|
b |Lorem ipsum dolor sit amet,|
-------------------------------------------------------------------------
Again, these are exaggerated, a quarter space in real life this would only be a pixel or two.
Our set of rules is very simple, so we could very easily solve this problem.
Consider how complicated that debug would be when you've got a justification engine that has to support:
Not to mention, most of this is actually handed off to the GPU to actually draw.
Bear in mind that you are, in fact, altering the dom and forcing the entire block to re-render as a result. Given the number of factors involved, it's very hard to expect two different dom structures to always render exactly the same.
Concerning the letters that seem to occasionally shift around a bit, most of what I've said about the complexity of how these things gets handled continues to apply to the following.
Again, in an effort to improve speed, numbers are often rounded down before passing them over to the GPU for rendering.
By way of providing a simplified example, a hundredth of a pixel does not make much difference, it would be imperceptible to the human eye and is therefore a waste of processing power. So you decide to round to the nearest pixel.
Let's say the character draw sequence is:
Characters with widths:
10.2 10.3 10.4 10.2 10.6 11 8.9 9.9 7.6 9.2 9.8 10.4 10.4 11.1 10.5 10.5
Starting at point 0 will draw at:
0 10 21 31 41 52 63 72 82 89 98 108 119 129 140 151
But what if you reach the end of a node? Well that's easy, just start the new node at the next draw position and move on?
Characters with widths:
10.2 10.3 10.4 10.2 10.6 11 8.9|9.9 7.6 9.2 9.8 10.4 10.4 11.1 10.5 10.5
Starting at point 0 will draw at:
0 10 21 31 41 52 63 72 82 89 99 108 119 129 140 151
Even though the underlying numbers are different, the render location remains the same for every location except the 11th character due to the rounding.
It may not necessarily be the start position, again, there is a tremendous amount of complexity here. The symptoms do point to a rounding threshold of some kind. As I said before, when rendering two different dom trees, differences should be expected.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With