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

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?



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

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