Skip to content

Instantly share code, notes, and snippets.

@tophtucker
Last active May 24, 2024 12:31
Show Gist options
  • Save tophtucker/62f93a4658387bb61e4510c37e2e97cf to your computer and use it in GitHub Desktop.
Save tophtucker/62f93a4658387bb61e4510c37e2e97cf to your computer and use it in GitHub Desktop.
Measure text
license: mit

To approximate the width of strings without touching the DOM. (Or rather: getBoundingClientRect is used to calibrate a static function.) Choose a font-family upfront; font-size can be passed as a parameter.

Calibration script seems to suck in Firefox… but the generated calibrated function should work fine in Firefox!

Instructions

  1. Run your finger across the smooth depressed rectangle below your keyboard until the tailed triangle on your screen appears over the rectangle with the word 'helvetica' in the upper right of the window above (beside "font-family:")
  2. Press your finger, hard, into the smooth rectangle
  3. Strike a key; repeat until the name of your desired font appears
  4. Stroke the smooth rectangle left-to-right until the tailed triangle appears over the small 'Go' in the other rectangle
  5. Apply pressure to your finger until you feel a click
  6. Run finger across smooth rectangle top-to-bottom until tailed triangle appears inside box below "Copy and paste me:"
  7. Again press finger down (i.e. toward ground, not toward self) and release
  8. Depress the "command" button on your keyboard and hold it there, then depress the "c" button, then release both

Helvetica default version

function measureText(string, fontSize = 10) {
  const widths = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.2796875,0.2765625,0.3546875,0.5546875,0.5546875,0.8890625,0.665625,0.190625,0.3328125,0.3328125,0.3890625,0.5828125,0.2765625,0.3328125,0.2765625,0.3015625,0.5546875,0.5546875,0.5546875,0.5546875,0.5546875,0.5546875,0.5546875,0.5546875,0.5546875,0.5546875,0.2765625,0.2765625,0.584375,0.5828125,0.584375,0.5546875,1.0140625,0.665625,0.665625,0.721875,0.721875,0.665625,0.609375,0.7765625,0.721875,0.2765625,0.5,0.665625,0.5546875,0.8328125,0.721875,0.7765625,0.665625,0.7765625,0.721875,0.665625,0.609375,0.721875,0.665625,0.94375,0.665625,0.665625,0.609375,0.2765625,0.3546875,0.2765625,0.4765625,0.5546875,0.3328125,0.5546875,0.5546875,0.5,0.5546875,0.5546875,0.2765625,0.5546875,0.5546875,0.221875,0.240625,0.5,0.221875,0.8328125,0.5546875,0.5546875,0.5546875,0.5546875,0.3328125,0.5,0.2765625,0.5546875,0.5,0.721875,0.5,0.5,0.5,0.3546875,0.259375,0.353125,0.5890625]
  const avg = 0.5279276315789471
  return string
    .split('')
    .map(c => c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg)
    .reduce((cur, acc) => acc + cur) * fontSize
}

Backstory

So we have this chart in React and we want the right axis to autosize to fit labels of varying length. One solution is to render the whole axis, get its bounding box, and then re-render the whole chart with the computer axis width as a margin parameter. But that sorta sucks because you double your render cycles (or fracture your render code in weird unreadable ways, with un-React-like DOM manipulation).

This solution is fragile and hacky in other ways, but may be perfectly suitable in many cases: just do the math with hardcoded character widths! Sorry! The measureText function here is calibrated once to a font (by you! manually! by default, Helvetica, natch) and then takes a string and font size and returns the computed rendered width. Just copy and paste the measureText function into your project.

Obviously assumes no kerning, no ligatures, no wrapping, etc. Only supports ASCII; otherwise it assumes a default average character width. It's also missing a lot of weird thin spaces and such.

This has surely been done a million times (including by my coworker it turns out lol), there's stuff like adambisek/string-pixel-width, but I couldn't quickly find something quick & easy & copypastable.

Good night.

// Update button onclick handler
function updateFontFamily(event) {
event.preventDefault()
d3.select("svg").style("font-family", d3.select("#input").node().value)
calibrate()
}
// This evaluates and prints the updated function
function calibrate() {
var widths = calculate()
// this is weird and broken-up cuz "normal" function declarations don't seem to play well with eval
var fnName = 'measureText'
var fnParams = '(string, fontSize = 10)'
var fn = `{
const widths = ${JSON.stringify(widths)}
const avg = ${d3.mean(widths.filter(d => d !== 0))}
return string
.split('')
.map(c => c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg)
.reduce((cur, acc) => acc + cur) * fontSize
}`
eval(`${fnName} = function${fnParams} ${fn}`)
d3.select("#output").text(`function ${fnName}${fnParams} ${fn}`)
// fill in example textbox
var ex = `${fnName}('Hello world', 12)`
d3.select("#ex").text(`${ex}
// ${eval(ex)}`)
}
// This does the actual calculation
function calculate() {
var chars = []
var widths = []
// For "!" through "~"...
for (var i = 33; i < 127; i++) {
chars[i] = String.fromCharCode(i)
}
// Create element, measure bounding client rect, put in array
var letter = d3.select("#calibrate").selectAll("text.calibrate")
.data(chars)
.enter()
.append("text")
.classed("calibrate", true)
.text(d => d)
.each(function(d) {
var bb = this.getBoundingClientRect()
widths.push(bb.width)
})
// A naked space (charCode 32) doesn't take up any space, so, special case...
var space = d3.select("#calibrate").append("text")
.classed("calibrate", true)
.text("t t")
.each(function(d) {
var bb = this.getBoundingClientRect()
widths[32] = bb.width - 2 * widths["t".charCodeAt(0)]
})
// Clean up after self
d3.select("svg").selectAll("text.calibrate").remove()
// These are from 10px font; normalize to 1px, and return
return widths.map(d => d/10)
}
var demo
function startDemo() {
var samples = [
'Hello world',
'No DOM measurements in this demo',
'The quick brown hare trailed the tortoise in the limit',
'I am at the office eating a bagel',
'Toph',
'Notice the kerning-induced error above',
'That\'s my given name to myself by myself, see I see movies',
'ASCII only :(',
'The moré ‘Unicöde’ the worse the åpprøxîmátioñ'
]
var g = d3.select('#demo')
.selectAll('g')
.data(samples)
var gEnter = g.enter()
.append('g')
.each(function(str) {
var g = d3.select(this)
g.append('text')
.classed('str', true)
.text(str)
g.append('line')
.attr('marker-end', 'url(#mark)')
.attr('marker-start', 'url(#mark)')
g.append('text')
.classed('px', true)
.attr('dx', 5)
.attr('dy', 3.5)
})
g = g.merge(gEnter)
.attr('transform', (d,i) => `translate(30,${i * 50 + 50})`)
render()
clearInterval(demo)
demo = setInterval(render, 750)
function render() {
g.each(function(str) {
var randomSize = Math.random() * 40 + 10
var measure = measureText(str, randomSize)
var g = d3.select(this)
g.select("text.str")
.style('font-size', `${randomSize}px`)
g.select('line')
.attr('y1', -randomSize/3)
.attr('y2', -randomSize/3)
.attr('x2', measure)
g.select("text.px")
.attr('x', measure)
.attr('y', -randomSize/3)
.text(`${measure.toFixed(0)}px`)
})
}
}
function stopDemo() {
clearInterval(demo)
}
<!DOCTYPE html>
<html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<link rel="stylesheet" type="text/css" href="style.css"/>
<body>
<div id="controls">
<form onSubmit="return updateFontFamily(event)">
font-family: <input id="input" value="helvetica">;
<input type="submit" value="Go">
</form>
<hr/>
<p>Copy and paste me:</p>
<textarea id="output" onclick="this.focus();this.select()" readonly="readonly"></textarea>
<p>Example usage:</p>
<textarea id="ex" readonly="readonly"></textarea>
</div>
<svg>
<defs>
<marker id="mark" viewBox="0 0 10 10" refX="1" refY="6"
markerWidth="6" markerHeight="12" orient="auto">
<line y1="0" y2="12" x1="1" x2="1" />
</marker>
</defs>
<g id="calibrate"></g>
<g id="demo"></g>
</svg>
</body>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="calibrate.js" charset="utf-8"></script>
<script src="demo.js" charset="utf-8"></script>
<script>
var measureText
calibrate()
startDemo()
</script>
</html>
html, body {
width: 100%;
height: 100%;
margin: 0;
}
svg {
font-family: helvetica;
overflow: visible;
width: 100%;
height: 100%;
}
line {
stroke: violet;
}
#calibrate text {
font-size: 10px;
}
text.px {
fill: violet;
font-size: 10px;
}
#controls {
position: absolute;
background: rgba(255,255,255,.9);
border: 3px double black;
right: 10px;
top: 10px;
padding: 10px;
font-family: sans-serif;
font-size: 11px;
}
#controls form {
font-family: monospace;
}
#controls input {
width: 75px;
font-family: monospace;
}
#controls input[type="submit"] {
width: auto;
}
#controls p {
text-transform: uppercase;
margin-bottom: 5px;
color: gray;
}
#controls textarea {
width: 210px;
overflow: scroll;
margin: 0;
padding: 2px;
white-space: pre;
font-family: monospace;
}
#controls #output {
border: 1px solid #aca;
background: #efe;
height: 110px;
}
#controls #ex {
height: 30px;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment