Canvas text rendering (blurry)

DOM quality text on the Canvas.

A closer look

If you zoom in on the DOM text you will see the following (top is canvas, bottom is DOM, center is hopefully at pixel size (not on retina displays))

enter image description here

As you can see there are coloured sections on the bottom text. This is because it has been rendered using a technique called true type

Note using true type is an optional setting on browsers and operating systems. If you have it turned off or have a very low res device the zoomed text above will look the same (no coloured pixels in the bottom image)

Pixels and sub pixels

When you look closely at a LCD display you will see that each pixel is made up of 3 sub pixels arranged in a row, one each for red, green, and blue. To set a pixel you supply the RGB intensity for each colour channel, and the appropriate RGB sub pixels are set. We generally accept that red is first and blue last, but the reality is it does not matter what order the colours are as long as they are close to each other you get the same result.

When you stop thinking about colour and just about controllable image elements you triple the horizontal resolution of your device. As most text is monochromatic you don’t have to worry too much about the alignment of the RGB subpixels and you can render the text to the sub pixel rather than the whole pixel and thus get high quality text. The sub pixels are so small most people do not notice the slight colour distortions, and the benefit is well worth the slightly dirty look.

Why no true type for canvas

When using sub pixels you need to have full control of each, including the alpha value. For the display drivers alpha applies to all the sub pixels of a pixel, you can not have blue at alpha 0.2 and red on the same pixel at alpha 0.7. But if you know what the sub pixel values are under each sub pixel you can do the alpha calculations instead of letting the hardware do it. That gives you algha control at a sub pixel level.

Unfortunately (no… fortunate for 99.99% of cases) the canvas allows transparency, but you have no way of knowing what the sub pixels under the canvas are doing, they can be any colour, and hence you can not do the alpha calculations needed to use sub pixels effectively.

Home grown subpixel text.

But you don’t have to have a transparent canvas and if you make all your pixels non transparent (alpha = 1.0) you regain sub pixel alpha control.

The following function draws canvas text using sub pixels. It is not very fast but it does get better quality text.

It works by rendering the text at 3 times the normal width. Then it uses the extra pixels to calculate the sub pixel values and when done puts the sub pixel data onto the canvas.

Update When I wrote this answer I totaly forgot about zoom settings. Using sub pixels requiers a presise match between display physical pixel size and DOM pixel size. If you have zoomed in or out this will not be so and thus locating sub pixels becomes much more difficult.

I have updated the demos to try to detect the zoom settings. As there is not standard way to do this I have just used devicePixelRatio which for FF and Chrome are !== 1 when zoomed (And as I dont have a retina decvice I am only guessing if the bottom demo works). If you wish to see the demo correctly and you do not get a zoom warning though are still zoomed set the zoom to 1.

Addistionaly you may wish to set the zoom to 200% and use the bottom demo as it seems that zooming in reduces the DOM text quality considerably, while the canvas sub pixel maintains the high quality.

Top text is normal Canvas text, center is (home made) sub pixel text on canvas and bottom is DOM text

PLEASE note if you have Retina Display or a very high resolution display you should view the snippet below this one if you do not see high quality canvas text.

Standard 1 to 1 pixel demo.

var createCanvas =function(w,h){
    var c = document.createElement("canvas");
    c.width  = w;
    c.height = h;
    c.ctx    = c.getContext("2d");
   // document.body.appendChild(c);
    return c;
}

// converts pixel data into sub pixel data
var subPixelBitmap = function(imgData){
    var spR,spG,spB; // sub pixels
    var id,id1; // pixel indexes
    var w = imgData.width;
    var h = imgData.height;
    var d = imgData.data;
    var x,y;
    var ww = w*4;
    var ww4 = ww+4;
    for(y = 0; y < h; y+=1){
        for(x = 0; x < w; x+=3){
            var id = y*ww+x*4;
            var id1 = Math.floor(y)*ww+Math.floor(x/3)*4;
            spR = Math.sqrt(d[id + 0] * d[id + 0] * 0.2126 + d[id + 1] * d[id + 1] * 0.7152 + d[id + 2] * d[id + 2] * 0.0722);
            id += 4;
            spG = Math.sqrt(d[id + 0] * d[id + 0] * 0.2126 + d[id + 1] * d[id + 1] * 0.7152 + d[id + 2] * d[id + 2] * 0.0722);
            id += 4;
            spB = Math.sqrt(d[id + 0] * d[id + 0] * 0.2126 + d[id + 1] * d[id + 1] * 0.7152 + d[id + 2] * d[id + 2] * 0.0722);
            
            d[id1++] = spR;
            d[id1++] = spG;
            d[id1++] = spB;
            d[id1++] = 255;  // alpha always 255
        }
    }
    return imgData;
}

// Assume default textBaseline and that text area is contained within the canvas (no bits hanging out)
// Also this will not work is any pixels are at all transparent
var subPixelText = function(ctx,text,x,y,fontHeight){
    var width = ctx.measureText(text).width + 12; // add some extra pixels
    var hOffset = Math.floor(fontHeight *0.7);
    var c = createCanvas(width * 3,fontHeight);
    c.ctx.font = ctx.font;
    c.ctx.fillStyle = ctx.fillStyle;
    c.ctx.fontAlign = "left";
    c.ctx.setTransform(3,0,0,1,0,0); // scale by 3
    // turn of smoothing
    c.ctx.imageSmoothingEnabled = false;    
    c.ctx.mozImageSmoothingEnabled = false;    
    // copy existing pixels to new canvas
    c.ctx.drawImage(ctx.canvas,x -2, y - hOffset, width,fontHeight,0,0, width,fontHeight );
    c.ctx.fillText(text,0,hOffset);    // draw thw text 3 time the width
    // convert to sub pixel 
    c.ctx.putImageData(subPixelBitmap(c.ctx.getImageData(0,0,width*3,fontHeight)),0,0);
    ctx.drawImage(c,0,0,width-1,fontHeight,x,y-hOffset,width-1,fontHeight);
    // done
}


var globalTime;
// render loop does the drawing
function update(timer) { // Main update loop
    globalTime = timer;
    ctx.setTransform(1,0,0,1,0,0); // set default
    ctx.globalAlpha= 1;
    ctx.fillStyle = "White";
    ctx.fillRect(0,0,canvas.width,canvas.height)
    ctx.fillStyle = "black";
    ctx.fillText("Canvas text is Oh hum "+ globalTime.toFixed(0),6,20);
    subPixelText(ctx,"Sub pixel text is best "+ globalTime.toFixed(0),6,45,25);
    div.textContent = "DOM is off course perfect "+ globalTime.toFixed(0);
    requestAnimationFrame(update);
}

function start(){
    document.body.appendChild(canvas);
    document.body.appendChild(div);
    ctx.font = "20px Arial";
    requestAnimationFrame(update);  // start the render
}

var canvas = createCanvas(512,50); // create and add canvas
var ctx = canvas.ctx;  // get a global context
var div = document.createElement("div");
div.style.font = "20px Arial";
div.style.background = "white";
div.style.color = "black";
if(devicePixelRatio !== 1){
   var dir = "in"
   var more = "";
   if(devicePixelRatio > 1){
       dir = "out";
   }
   if(devicePixelRatio === 2){
       div.textContent = "Detected a zoom of 2. You may have a Retina display or zoomed in 200%. Please use the snippet below this one to view this demo correctly as it requiers a precise match between DOM pixel size and display physical pixel size. If you wish to see the demo anyways just click this text. ";

       more = "Use the demo below this one."
   }else{
       div.textContent = "Sorry your browser is zoomed "+dir+".This will not work when DOM pixels and Display physical pixel sizes do not match. If you wish to see the demo anyways just click this text.";
       more = "Sub pixel display does not work.";
   }
    document.body.appendChild(div);
    div.style.cursor = "pointer";
    div.title = "Click to start the demo.";
    div.addEventListener("click",function(){          
        start();
        var divW = document.createElement("div");
        divW.textContent = "Warning pixel sizes do not match. " + more;
        divW.style.color = "red";
        document.body.appendChild(divW);
    });

}else{
    start();
}






          

1 to 2 pixel ratio demo.

For retina, very high resolution, or zoomed 200% browsers.

var createCanvas =function(w,h){
    var c = document.createElement("canvas");
    c.width  = w;
    c.height = h;
    c.ctx    = c.getContext("2d");
   // document.body.appendChild(c);
    return c;
}

// converts pixel data into sub pixel data
var subPixelBitmap = function(imgData){
    var spR,spG,spB; // sub pixels
    var id,id1; // pixel indexes
    var w = imgData.width;
    var h = imgData.height;
    var d = imgData.data;
    var x,y;
    var ww = w*4;
    var ww4 = ww+4;
    for(y = 0; y < h; y+=1){
        for(x = 0; x < w; x+=3){
            var id = y*ww+x*4;
            var id1 = Math.floor(y)*ww+Math.floor(x/3)*4;
            spR = Math.sqrt(d[id + 0] * d[id + 0] * 0.2126 + d[id + 1] * d[id + 1] * 0.7152 + d[id + 2] * d[id + 2] * 0.0722);
            id += 4;
            spG = Math.sqrt(d[id + 0] * d[id + 0] * 0.2126 + d[id + 1] * d[id + 1] * 0.7152 + d[id + 2] * d[id + 2] * 0.0722);
            id += 4;
            spB = Math.sqrt(d[id + 0] * d[id + 0] * 0.2126 + d[id + 1] * d[id + 1] * 0.7152 + d[id + 2] * d[id + 2] * 0.0722);
            
            d[id1++] = spR;
            d[id1++] = spG;
            d[id1++] = spB;
            d[id1++] = 255;  // alpha always 255
        }
    }
    return imgData;
}

// Assume default textBaseline and that text area is contained within the canvas (no bits hanging out)
// Also this will not work is any pixels are at all transparent
var subPixelText = function(ctx,text,x,y,fontHeight){
    var width = ctx.measureText(text).width + 12; // add some extra pixels
    var hOffset = Math.floor(fontHeight *0.7);
    var c = createCanvas(width * 3,fontHeight);
    c.ctx.font = ctx.font;
    c.ctx.fillStyle = ctx.fillStyle;
    c.ctx.fontAlign = "left";
    c.ctx.setTransform(3,0,0,1,0,0); // scale by 3
    // turn of smoothing
    c.ctx.imageSmoothingEnabled = false;    
    c.ctx.mozImageSmoothingEnabled = false;    
    // copy existing pixels to new canvas
    c.ctx.drawImage(ctx.canvas,x -2, y - hOffset, width,fontHeight,0,0, width,fontHeight );
    c.ctx.fillText(text,0,hOffset);    // draw thw text 3 time the width
    // convert to sub pixel 
    c.ctx.putImageData(subPixelBitmap(c.ctx.getImageData(0,0,width*3,fontHeight)),0,0);
    ctx.drawImage(c,0,0,width-1,fontHeight,x,y-hOffset,width-1,fontHeight);
    // done
}


var globalTime;
// render loop does the drawing
function update(timer) { // Main update loop
    globalTime = timer;
    ctx.setTransform(1,0,0,1,0,0); // set default
    ctx.globalAlpha= 1;
    ctx.fillStyle = "White";
    ctx.fillRect(0,0,canvas.width,canvas.height)
    ctx.fillStyle = "black";
    ctx.fillText("Normal text is Oh hum "+ globalTime.toFixed(0),12,40);
    subPixelText(ctx,"Sub pixel text is best "+ globalTime.toFixed(0),12,90,50);
    div.textContent = "DOM is off course perfect "+ globalTime.toFixed(0);
    requestAnimationFrame(update);
}


var canvas = createCanvas(1024,100); // create and add canvas
canvas.style.width = "512px";
canvas.style.height = "50px";
var ctx = canvas.ctx;  // get a global context
var div = document.createElement("div");
div.style.font = "20px Arial";
div.style.background = "white";
div.style.color = "black";
function start(){
    document.body.appendChild(canvas);
    document.body.appendChild(div);
    ctx.font = "40px Arial";
    requestAnimationFrame(update);  // start the render
}

if(devicePixelRatio !== 2){
   var dir = "in"
   var more = "";
   div.textContent = "Incorrect pixel size detected. Requiers zoom of 2. See the answer for more information. If you wish to see the demo anyways just click this text. ";


    document.body.appendChild(div);
    div.style.cursor = "pointer";
    div.title = "Click to start the demo.";
    div.addEventListener("click",function(){          
        start();
        var divW = document.createElement("div");
        divW.textContent = "Warning pixel sizes do not match. ";
        divW.style.color = "red";
        document.body.appendChild(divW);
    });

}else{
    start();
}





          

For even better results.

To get the best results you will need to use webGL. It is a relatively simple modification from standard anti-aliasing to sub pixel anti-aliasing. An example of standard vector text rendering using webGL can be found at WebGL PDF

WebGL API will happily sit besides 2D canvas API and copying the result of webGl rendered content to a 2D canvas is as simple as rendering an imagecontext.drawImage(canvasWebGL,0,0)

Leave a Comment