'How to fit text to a precise width on html canvas?
How can I fit a single-line string of text to a precise width on an html5 canvas? What I've tried so far is to write text at an initial font size, measure the text's width with measureText(my_text).width
, and then calculate a new font size based on the ratio between my desired text width and the actual text width. It gives results that are approximately correct, but depending on the text there's some white space at the edges.
Here's some example code:
// Draw "guard rails" with 200px space in between
c.fillStyle = "lightgrey";
c.fillRect(90, 0, 10, 200);
c.fillRect(300, 0, 10, 200);
// Measure how wide the text would be with 100px font
var my_text = "AA";
var initial_font_size = 100;
c.font = initial_font_size + "px Arial";
var initial_text_width = c.measureText(my_text).width;
// Calculate the font size to exactly fit the desired width of 200px
var desired_text_width = 200;
new_font_size = initial_font_size * desired_text_width / initial_text_width;
// Draw the text with the new font size
c.font = new_font_size + "px Arial";
c.fillStyle = "black";
c.textBaseline = "top";
c.fillText(my_text, 100, 0, 500);
The result is perfect for some strings, like "AA"
:
But for other strings, like "BB"
, there's a gap at the edges, and you can see that the text doesn't reach to the "guardrails":
How could I make it so that the text always reaches right to the edges?
Solution 1:[1]
Measuring text width
Measuring text is problematic on many levels.
The full and experimental textMetric
has been defined for many years yet is available only on 1 main stream browser (Safari), hidden behind flags (Chrome), covered up due to bugs (Firefox), status unknown (Edge, IE).
Using width
only
At best you can use the width
property of the object returned by ctx.measureText
to estimate the width. This width is greater or equal to the actual pixel width (left to right most). Note web fonts must be fully loaded or the width may be that of the placeholder font.
Brute force
The only method that seams to work reliably is unfortunately a brute force technique that renders the font to a temp / or work canvas and calculates the extent by querying the pixels.
This will work across all browsers that support the canvas.
It is not suitable for real-time animations and applications.
The following function
Will return an object with the following properties
width
width in canvas pixels of textleft
distance from left of first pixel in canvas pixelsright
distance from left to last detected pixel in canvas pixelsrightOffset
distance in canvas pixel from measured text width and detected right edgemeasuredWidth
the measured width as returned byctx.measureText
baseSize
the font size in pixelsfont
the font used to measure the text
It will return
undefined
if width is zero or the string contains no visible text.
You can then use the fixed size font and 2D transform to scale the text to fit the desired width. This will work for very small fonts resulting in higher quality font rendering at smaller sizes.
The accuracy is dependent on the size of the font being measure. The function uses a fixed font size of 120px
you can set the base size by passing the property
The function can use partial text (Short cut) to reduce RAM and processing overheads. The property rightOffset
is the distance in pixels from the right ctx.measureText
edge to the first pixel with content.
Thus you can measure the text "CB"
and use that measure to accurately align any text starting with "C"
and ending with "B"
Example if using short cut text
const txtSize = measureText({font: "arial", text: "BB"});
ctx.font = txtSize.font;
const width = ctx.measureText("BabcdefghB").width;
const actualWidth = width - txtSize.left - txtSize.rightOffset;
const scale = canvas.width / actualWidth;
ctx.setTransform(scale, 0, 0, scale, -txtSize.left * scale, 0);
ctx.fillText("BabcdefghB",0,0);
measureText
function
const measureText = (() => {
var data, w, size = 120; // for higher accuracy increase this size in pixels.
const isColumnEmpty = x => {
var idx = x, h = size * 2;
while (h--) {
if (data[idx]) { return false }
idx += can.width;
}
return true;
}
const can = document.createElement("canvas");
const ctx = can.getContext("2d");
return ({text, font, baseSize = size}) => {
size = baseSize;
can.height = size * 2;
font = size + "px "+ font;
if (text.trim() === "") { return }
ctx.font = font;
can.width = (w = ctx.measureText(text).width) + 8;
ctx.font = font;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(text, 0, size);
data = new Uint32Array(ctx.getImageData(0, 0, can.width, can.height).data.buffer);
var left, right;
var lIdx = 0, rIdx = can.width - 1;
while(lIdx < rIdx) {
if (left === undefined && !isColumnEmpty(lIdx)) { left = lIdx }
if (right === undefined && !isColumnEmpty(rIdx)) { right = rIdx }
if (right !== undefined && left !== undefined) { break }
lIdx += 1;
rIdx -= 1;
}
data = undefined; // release RAM held
can.width = 1; // release RAM held
return right - left >= 1 ? {
left, right, rightOffset: w - right, width: right - left,
measuredWidth: w, font, baseSize} : undefined;
}
})();
Usage example
The example use the function above and short cuts the measurement by supplying only the first and last non white space character.
Enter text into the text input.
- If the text is too large to fit the canvas the console will display a warning.
- If the text scale is greater than 1 (meaning the displayed font is larger than the measured font) the console will show a warning as there may be some loss of alignment precision.
inText.addEventListener("input", updateCanvasText);
const ctx = canvas.getContext("2d");
canvas.height = canvas.width = 500;
function updateCanvasText() {
const text = inText.value.trim();
const shortText = text[0] + text[text.length - 1];
const txtSize = measureText({font: "arial", text: text.length > 1 ? shortText: text});
if(txtSize) {
ctx.clearRect(0,0,ctx.canvas.width, ctx.canvas.height)
ctx.font = txtSize.font;
const width = ctx.measureText(text).width;
const actualWidth = width - txtSize.left - txtSize.rightOffset;
const scale = (canvas.width - 20) / actualWidth;
console.clear();
if(txtSize.baseSize * scale > canvas.height) {
console.log("Font scale too large to fit vertically");
} else if(scale > 1) {
console.log("Scaled > 1, can result in loss of precision ");
}
ctx.textBaseline = "top";
ctx.fillStyle = "#000";
ctx.textAlign = "left";
ctx.setTransform(scale, 0, 0, scale, 10 - txtSize.left * scale, 0);
ctx.fillText(text,0,0);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = "#CCC8";
ctx.fillRect(0, 0, 10, canvas.height);
ctx.fillRect(canvas.width - 10, 0, 10, canvas.height);
} else {
console.clear();
console.log("Empty string ignored");
}
}
const measureText = (() => {
var data, w, size = 120;
const isColumnEmpty = x => {
var idx = x, h = size * 2;
while (h--) {
if (data[idx]) { return false }
idx += can.width;
}
return true;
}
const can = document.createElement("canvas");
const ctx = can.getContext("2d");
return ({text, font, baseSize = size}) => {
size = baseSize;
can.height = size * 2;
font = size + "px "+ font;
if (text.trim() === "") { return }
ctx.font = font;
can.width = (w = ctx.measureText(text).width) + 8;
ctx.font = font;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(text, 0, size);
data = new Uint32Array(ctx.getImageData(0, 0, can.width, can.height).data.buffer);
var left, right;
var lIdx = 0, rIdx = can.width - 1;
while(lIdx < rIdx) {
if (left === undefined && !isColumnEmpty(lIdx)) { left = lIdx }
if (right === undefined && !isColumnEmpty(rIdx)) { right = rIdx }
if (right !== undefined && left !== undefined) { break }
lIdx += 1;
rIdx -= 1;
}
data = undefined; // release RAM held
can.width = 1; // release RAM held
return right - left >= 1 ? {left, right, rightOffset: w - right, width: right - left, measuredWidth: w, font, baseSize} : undefined;
}
})();
body {
font-family: arial;
}
canvas {
border: 1px solid black;
width: 500px;
height: 500px;
}
<label for="inText">Enter text </label><input type="text" id="inText" placeholder="Enter text..."/>
<canvas id="canvas"></canvas>
Note decorative fonts may not work, you may need to extend the height of the canvas in the function measureText
Solution 2:[2]
I had a similar problem in a project of mine. I needed to not only get the exact width of the text, but I also realised that if I rendered text at position X, it would sometimes flow to the left of X due to Side Bearings.
Try as I might, I couldn't get the DOM to give me those values, so I had to resort to SVG to accurately measure the text.
I ended up with the following solution to measure the text exactly, including the side bearing, or X offset that I would need to apply to get the pixels to appear in the right place.
This code has only been tested in Chrome and Firefox but should work in basically all modern browsers. It also supports the usage of web fonts, which simply needs to be loaded into the page, and can then be referenced by name.
class TextMeasurer {
constructor() {
const SVG_NS = "http://www.w3.org/2000/svg";
this.svg = document.createElementNS(SVG_NS, 'svg');
this.svg.style.visibility = 'hidden';
this.svg.setAttribute('xmlns', SVG_NS)
this.svg.setAttribute('width', 0);
this.svg.setAttribute('height', 0);
this.svgtext = document.createElementNS(SVG_NS, 'text');
this.svg.appendChild(this.svgtext);
this.svgtext.setAttribute('x', 0);
this.svgtext.setAttribute('y', 0);
document.querySelector('body').appendChild(this.svg);
}
/**
* Measure a single line of text, including the bounding box, inner size and lead and trail X
* @param {string} text Single line of text
* @param {string} fontFamily Name of font family
* @param {string} fontSize Font size including units
*/
measureText(text, fontFamily, fontSize) {
this.svgtext.setAttribute('font-family', fontFamily);
this.svgtext.setAttribute('font-size', fontSize);
this.svgtext.textContent = text;
let bbox = this.svgtext.getBBox();
let textLength = this.svgtext.getComputedTextLength();
// measure the overflow before and after the line caused by font side bearing
// Rendering should start at X + leadX to have the edge of the text appear at X
// when rendering left-aligned left-to-right
let baseX = parseInt(this.svgtext.getAttribute('x'));
let overflow = bbox.width - textLength;
let leadX = Math.abs(baseX - bbox.x);
let trailX = overflow - leadX;
return {
bbWidth: bbox.width,
textLength: textLength,
leadX: leadX,
trailX: trailX,
bbHeight: bbox.height
};
}
}
//Usage:
let m = new TextMeasurer();
let textDimensions = m.measureText("Hello, World!", 'serif', '12pt');
document.getElementById('output').textContent = JSON.stringify(textDimensions);
<body>
<div id="output"></div>
</body>
Solution 3:[3]
The problem you are facing is that TextMetrics.width represents the "advance width" of the text.
This answer explains pretty well what it is, and links to good resources.
The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
What you want here is the bounding-box width, and to get this, you need to calculate the sum of TextMetric.actualBoundingBoxLeft
+ TextMetric.actualBoundingBoxRight
.
Note also that when rendering the text, you will have to account for the actualBoundingBoxLeft
offset of the bounding-box to make it fit correctly.
Unfortunately, all browsers don't support the extended TextMetrics objects, and actually only Chrome really does, since Safari falsely returns the advance width for the bouding-box values. For other browsers, we're out of luck, and have to rely on ugly getImageData hacks.
const supportExtendedMetrics = 'actualBoundingBoxRight' in TextMetrics.prototype;
if( !supportExtendedMetrics ) {
console.warn( "Your browser doesn't support extended properties of TextMetrics." );
}
const canvas = document.getElementById('canvas');
const c = canvas.getContext('2d');
c.textBaseline = "top";
const input = document.getElementById('inp');
input.oninput = (e) => {
c.clearRect(0,0, canvas.width, canvas.height);
// Draw "guard rails" with 200px space in between
c.fillStyle = "lightgrey";
c.fillRect(90, 0, 10, 200);
c.fillRect(300, 0, 10, 200);
c.fillStyle = "black";
fillFittedText(c, inp.value, 100, 0, 200) ;
};
input.oninput();
function fillFittedText( ctx, text = "", x = 0, y = 0, target_width = ctx.canvas.width, font_family = "Arial" ) {
let font_size = 1;
const updateFont = () => {
ctx.font = font_size + "px " + font_family;
};
updateFont();
let width = getBBOxWidth(text);
// first pass width increment = 1
while( width && width <= target_width ) {
font_size++;
updateFont();
width = getBBOxWidth(text);
}
// second pass, the other way around, with increment = -0.1
while( width && width > target_width ) {
font_size -= 0.1;
updateFont();
width = getBBOxWidth(text);
}
// revert to last valid step
font_size += 0.1;
updateFont();
// we need to measure where our bounding box actually starts
const offset_left = c.measureText(text).actualBoundingBoxLeft || 0;
ctx.fillText(text, x + offset_left, y);
function getBBOxWidth(text) {
const measure = ctx.measureText(text);
return supportExtendedMetrics ?
(measure.actualBoundingBoxLeft + measure.actualBoundingBoxRight) :
measure.width;
}
}
<input type="text" id="inp" value="BB">
<canvas id="canvas" width="500"></canvas>
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | Blindman67 |
Solution 2 | Peter |
Solution 3 |