How to fit text to a precise width on html canvas?-ThrowExceptions

Exception or error:

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":

enter image description here

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”:

enter image description here

How could I make it so that the text always reaches right to the edges?

How to solve:

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 text
    • left distance from left of first pixel in canvas pixels
    • right distance from left to last detected pixel in canvas pixels
    • rightOffset distance in canvas pixel from measured text width and detected right edge
    • measuredWidth the measured width as returned by ctx.measureText
    • baseSize the font size in pixels
    • font 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

###

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>

Leave a Reply

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