Davi Doro
Davi Doro

Reputation: 378

Precise width of a monospace character

I'm working on a code editor with syntax highlighting and I need to know the precise width of a monospace character. I use this value to calculate the position of a character on a line, which I need to know so I can place various GUI elements, such as text cursors (there can be multiple), selection rectangles, warning tooltips, etc. Up until now I've been using the following function:

    function getCharacterWidth(char, fontFamily, fontSize) {
        let span = document.createElement("span");
        span.style.fontFamily = fontFamily;
        span.style.fontSize = fontSize;
        span.style.position = "absolute";
        span.style.visibility = "hidden";
        span.style.width = "auto";
        span.style.whiteSpace = "nowrap";
        span.style.padding = "0";
        span.style.margin = "0";
        span.style.letterSpacing = "0px";
        span.style.wordSpacing = "0px";
        span.innerText = char;
        document.body.appendChild(span);
        
        let width = span.getBoundingClientRect().width;
        span.remove();
        
        return width;
    }

It has been working great, but then I noticed a problem on Google Chrome. When my text editor is rendering a large line, with thousands of characters, the character position is not being properly calculated because of rounding issues. It seems that on Google Chrome, the width returned by getBoundingClientRect() has a precision of at most 5 decimal places, which is not ideal for my use case. On Firefox, the precision seems to be much higher, going up to 15 decimal places, which is why I never had this problem there.

After some digging, I heard about this idea of calculating the width of a character based on the width of a span containing thousands of that character (https://stackoverflow.com/a/56379770/2197150). So, in my original function I replaced span.innerText = char with span.innerText = char.repeat(10000) and returned width / 10000. It helped, but the calculation is still perceptibly off when I'm dealing with large lines.

So here I am. How can I calculate the width of a character with high precision, like Firefox does, in other browsers?

Upvotes: 3

Views: 760

Answers (2)

Davi Doro
Davi Doro

Reputation: 378

I came up with a solution that has been working well for my use case. The idea is to calculate the width of a character based on the largest line of my editor. My line elements look something like this:

<div class="line"><span class="keyword">let</span><span class="space"> </span><span class="identifier">foo</span></div>

If we want to calculate the width of a character based on that line, here is what we do:

let lineElement = document.querySelector(".line");
let lineWidth = lineElement.getBoundingClientRect().width;
let charWidth = lineWidth / lineElement.textContent.length;

I keep track of the largest line I've rendered in my editor. Whenever I render a new largest line, I update charWidth based on that new line.

This allows me to calculate the position of a character at any given column within a line by doing a simple multiplication column * charWidth. It has been working pretty well so far, even for lines with 100000+ characters.

There is, however, one last thing I had to do to handle even larger lines. It seems that there is a bug on Google Chrome where, when you try to render a line with a huge text node, e.g. <span>many many characters...</span>, it will not give you the correct width of the element (see Inaccurate width of large element on Chrome). To overcome this issue, whenever I have to render a huge text, I split it into multiple spans, e.g. <span>many many </span><span>characters...</span>.

Now I can render 500000 characters long lines with font size 48 with no problems. Above that, things start to get weird again, with miscalculations and other weird browser behaviors. So I decided to set a hard limit of 500000 characters rendered in a line. Everything beyond the 500000nth characters is hidden from the user.

Upvotes: 2

kubi
kubi

Reputation: 965

This is not a clean solution, but I suspect there is no really clean solution.

You could keep a "column map", based on the syntax formatting <span>s you already have flying around. Say your highlighter gives you:

<div class="line">
var long_line_of_variables = <span class="number">123</span>;
</div>

you could measure this span's left offset + the column it appears in (= length of textContent of the siblings before it = 29) and arrive at

colums = {
  '29': 290.456
}

Now you can interpolate that column 14 is at 290.456*14/29=140.22 pixels.
The more spans we add, the better we can guess:

colums = {
  '29': 290.456,
  '2900': 28997.000 // whoops, not what we would have calculated!
}

This method is heuristic, so you'll need to find a strategy, which will work across browsers, zoom/font-scale settings etc., including

  • adding more and more spans to this "map"
  • but maybe "cleaning" it once in a while?
  • keep a global map or one per line?
  • be smart about interpolation: pick the nearest map entry (in the best interval between entries)
  • add more "span probes"
    • split long lines without any formatting into chunks of N characters, wrapping them in spans, or inserting empty spans in between
    • maybe just a probe span right at the end of each line?

Having worked on similar problems and heuristics, my advice would be: don't. :)
It involves lots of tweaking and testing, and probably is a cross-platform nightmare (e.g. compare font rendering and rounding in Firefox on Win vs Linux vs MacOS vs iOS). Instead, try to attach anything to a localized span. I understand that this is probably way harder -- lots of text editors struggle with long lines, esp. when it comes to MB-size compiled JS...

Upvotes: 1

Related Questions