Reputation: 378
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
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
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
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