Search code examples
htmltexthtml5-canvasmeasurement

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

  • 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