Measure per-letter dimension of text in JavaScript

TL;DR: Create a Range, set proper start and end points up to the text node with proper offset, then use Range.getBoundingClientRect() to get the dimensions.

As a part of the Lyricova Jukebox, we wanted to support inline karaoke swipe animation. With the time tags in the data, it is easy to figure out the time when the animation must reach a certain character. Then we need to figure out a way measure per character dimension for the animation to work.

Initially, I used to do in the way BalanceText does, modifying DOM content and CSS properties to measure the DOM node itself. BalanceText is a JavaScript library I recently read on, it inserts <br> tags in to text in order to achieve a balanced line length by overriding the DOM HTML source of the targeted node directly.

This indeed could work in a way, especially for BalancedText where it operates based on the “break opportunities” within the pure HTML source. However, that brings a problem to some applications that relies on those DOM elements. Unlike BalanceText, we don’t necessary need to modify the content, especially the DOM structure, just to measure its dimensions. The way BalanceText does discards the old DOM subtree, and replaces them with a new one by directly modifying the innerHTML property of the subtree. This means any event listeners and other references to the element are all unusable after the measurement. This is certainly something that we don’t want.

The Range API

After some digging through the internet, I found this Range API of the web, which can select a region of the webpage and measure its dimension. This API is used to represent selections by users in a webpage, but it isn’t necessary to modify the selection in order to use it.

In this way, we can pretty much say that we can measure the per-character width of text on the webpage without any modification to the DOM tree or even the UI.

I will demonstrate this feature by measuring an accumulating width of the text per character, i.e. the width of text from the beginning to the end of the nth character for all n.

The general idea is to:

  1. Locate the starting point. In this example we want to measure from the beginning of a node, so we can just use the Range.__proto__.setStartBefore(el) method.
  2. Recursively locate all text nodes in the sub-DOM tree.
  3. For each text node found
    1. Set the range to end on each character of the node by using Range.__proto__.setEnd(el, count).
    2. Get the width of the range with Range.__proto__.getBoundingClientRect().

Below is a sample code for the process in TypeScript.

/**
 * A generator that recursively find all text nodes in a subtree rooted at el.
 */
function* recursivelyFindTextNode(el: Node): Generator<Node> {
  if (el.nodeType === Node.TEXT_NODE) {
    yield el;
  } else if (el.nodeType === Node.ELEMENT_NODE) {
    for (let i = 0; i < el.childNodes.length; i++) {
      yield * recursivelyFindTextNode(el.childNodes[i]);
    }
  }
}

const result: number[] = [];
const range = document.createRange();
range.setStartBefore(el);

// expand all text nodes found.
const nodes = [...recursivelyFindTextNode(el)];

for (const textNode of nodes) {
const textLength = [...textNode.textContent].length;
  for (let i = 0; i < textLength; i++) {
    range.setEnd(textNode, i);
    const rect = range.getBoundingClientRect();
    if (rect.width !== 0) result.push(rect.width);
  }
}

// Get the total length of the text
range.setEndAfter(nodes.length > 0 ? nodes[nodes.length - 1] : el);
const rect = range.getBoundingClientRect();
result.push(rect.width);

Caveat

Since this API is measuring things rendered on screen directly, if you want to measure something, it has to be rendered first.

Also, if you want to measure the length of a long string of text in pixels, the text must be rendered in one line. This is achievable by a few lines of CSS:

white-space: pre;
display: inline;
width: fit-content;

Finally, here is a simple CodePen demo:

Leave a comment

Your email address will not be published. Required fields are marked *

*