Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why does Text.splitText() affect layout?

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)

when the words go marching in...

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:

enter image description here

Switching font-kerning to none didn't help either.

UPDATE: The issue is registered on the Blink tracker.

like image 826
raina77ow Avatar asked Aug 22 '17 13:08

raina77ow


People also ask

What is splitText?

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.

How can you split text in HTML?

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.


2 Answers

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.

like image 169
Salketer Avatar answered Oct 07 '22 11:10

Salketer


tl:dr Version

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.

Full answer

Considerations in Text Justification

Text justification is complicated to implement to say the least. To simplify, there are three basic considerations:

  1. Accuracy
  2. Aesthetics
  3. Speed

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.

Variance in browser implementation

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.

An Example

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:

  1. Create an array
  2. Working through the string until you find an end of word or punctuation mark followed by a space or end of string
  3. When you find an end of word, check to see if the length of the word + the length of all the words in the array plus the number of justification opportunities. If so, trim it off the string and put it in the array.
  4. When no more words can be added to the array, take the difference between the space needed and the space available, divide by the number of justification opportunities, and draw the array, placing that amount of space between each word.

Using this algorithm:

  1. Split the string

    'Lorem ipsum dolor sit amet, consectetur.' => 
        ['Lorem','ipsum','dolor','sit','amet,'] & 'consectetur.'
    
  2. 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.

  1. 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.'
    
  2. 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:

  1. Other (adjustable) css properties for justification
  2. Multiple languages and all their rules
  3. Fonts that have:
    • Variable character widths
    • Kerning metrics
    • vectors that don't necessarily line up with a pixel
  4. Inline (and inline-block) elements that have their own styling
  5. ...on and on

Not to mention, most of this is actually handed off to the GPU to actually draw.

Anyway all this to say

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.

Addendum

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:

  1. Take the start of the container
  2. Add the offset
  3. Draw the character at this location, rounded to the nearest pixel
  4. Update the offset by adding the unrounded width of the character.

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.

like image 39
LightBender Avatar answered Oct 07 '22 12:10

LightBender