16

I know this question has been asked many times, but I tried pretty much everything I could find on the net and still can't get the text to render properly in canvas no matter what (and with whatever combinations) I tried.

For the blurry lines and shapes problem, simply adding +0.5px to the coordinates solved the problem : however, this solution doesn't seem to work for text rendering.

Note : I never use CSS to set canvas width and height (just tried it once to check if setting size properties in both HTML and CSS would change anything). Also problem doesn't seem to be browser-related.

I tried :

  • creating canvas with HTML, then with javascript instead of html
  • setting width and height in HTML element, then with JS, then with both HTML and JS
  • add 0.5px to text coordinates with every possible combination
  • changing font-family and font-size
  • changing font-size unit (px, pt, em)
  • opening file with different browsers to check if anything changes
  • disabling alpha channel using canvas.getContext('2d', {alpha:false}) which just made most of my layers disapear without solving the problem

See comparison between canvas and html font rendering here : https://jsfiddle.net/balleronde/1e9a5xbf/

Is it even possible to get the text in canvas to render like text in dom element ? Any advice or suggestion would be greatly appreciated

Nora
  • 185
  • 1
  • 1
  • 9
  • Here is a thread already on High Definition canvas Elements. http://stackoverflow.com/questions/15661339/how-do-i-fix-blurry-text-in-my-html5-canvas – Sreekanth Oct 16 '16 at 02:49
  • Thank you, this code is awsome ! The text rendering is still a little blurry but it's totally readable now. Is their a way to get it as sharp as a dom text element, or is this the best one can get using canvas.? Here is what I get using Paul Lewis code : https://jsfiddle.net/balleronde/m7xuuv09/ – Nora Oct 16 '16 at 04:48

4 Answers4

24

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)

Blindman67
  • 51,134
  • 11
  • 73
  • 136
  • 1
    Thank you for your answer, I learned a lot about how pixels rendering works ! Your solution indeed increases text rendering quality but it is also very slow (especially considering that I need to display more than one canvas on my web page). Some letters appear with color glitches (blue using chrome), which illustrates your point about the fact that there is 'no way of knowing what the sub pixels under the canvas are doing' (i didn't check if this issue is browser-related yet). I will take time to dig into your code, although I'm still looking for a less greed solution in terms of performance. – Nora Oct 16 '16 at 23:02
  • @Nora I do not have transparent pixels, thus seeing coloured bars is because (A) because you have exceptionally good eyesight, or (B) have a very low resolution display, or (C) have a non standard zoom ( !== 1.0). I have made some changes in an attempt to detect zoom settings. True Type is an MS patented technology (hence this demo is sub pixel method not true type, (true type subdues the colour variance)) For speed you can use webGL, link shows the speed http://wdobbie.com/pdf/ and answer has all you need to make the mods for subpixel rendering. – Blindman67 Oct 17 '16 at 16:15
4

The simplest solution is to use window.devicePixelRatio to scale canvas resolution compared to its physical size.

E.g. having a canvas element:

<canvas id="canvas"></canvas>

set canvas size to a scale of 2 (the value of window.devicePixelRatio) of its physical size:

const canvas = document.getElementById('canvas')
canvas.width = canvas.clientWidth * 2
canvas.height = canvas.clientHeight * 2

and scale canvas context to 2:

const context = canvas.getContext('2d')
context.scale(2, 2)

Demo:

  1. With scale 1x1 Resolution 1x1

  2. With scale 1x2 Resolution 1x2

ilyavf
  • 139
  • 5
2

There is, however, a simpler solution.

    context.scale(0.3, 0.3)
    context.fillText("Hello there", canvas.width / 2 * 1 / 0.3, canvas.height * 2.8 / 4 * 1 / 0.3, canvas.width * 0.9 * 1 / 0.3);
    context.font = canvas.width / 15 + "px Arial";
    context.fillText("Want to talk? Mail me at mher@movsisyan.info", canvas.width / 2 * 1 / 0.3, canvas.height * 3.6 / 4 * 1 / 0.3, canvas.width * 0.9 * 1 / 0.3);
    context.fillText("Want to see my code? Find me on GitHub as MovsisyanM", canvas.width / 2 * 1 / 0.3, canvas.height * 3.8 / 4 * 1 / 0.3, canvas.width * 0.9 * 1 / 0.3);
    context.scale(1, 1)

Here I scale down the canvas context, which lets me use higher font px thus having better quality text.

I actually found out this technique because photoshop or some similar image editing program had the same problem. I hope this helps!

Movsisyan
  • 31
  • 4
0

Thanks a lot for all those explanations!

It's quite incredible that displaying a "simple string" in canvas in a neat manner is not supported by the default fillText(), and that we have to do such tricks to have a proper display, that's to say a display which is not a bit blurry or fuzzy. It's somehow like the "1px line drawing issue" in canvas (for which making +0.5 to coordinates helps but without solving completely the issue)...

I modified the code you provided above to make it support colored text (not only black and white text). I hope it can help.

In the function subPixelBitmap(), there is a little algorithm to average red/green/blue colors. It improves a little the string display in canvas (on Chrome), especially for small fonts. Maybe there are other algos which are even better: if you find one, I would be interested.

This figure shows the effect on display: Improved string display in canvas

Here is a working example that can be run online: working example on jsfiddle.net

The associated code is this one (check the above working example for the last version):

  canvas = document.getElementById("my_canvas");
  ctx = canvas.getContext("2d");
  ...

  // Display a string:
  // - nice way:
  ctx.font = "12px Arial";
  ctx.fillStyle = "red";
  subPixelText(ctx,"Hello World",50,50,25);  
  ctx.font = "bold 14px Arial";
  ctx.fillStyle = "red";
  subPixelText(ctx,"Hello World",50,75,25);
  // - blurry default way:  
  ctx.font = "12px Arial";
  ctx.fillStyle = "red";
  ctx.fillText("Hello World", 50, 100);    
  ctx.font = "bold 14px Arial";
  ctx.fillStyle = "red";
  ctx.fillText("Hello World", 50, 125);

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;
    for(y = 0; y < h; y+=1){ // (go through all y pixels)
        for(x = 0; x < w-2; x+=3){ // (go through all groups of 3 x pixels)
            var id = y*ww+x*4; // (4 consecutive values: id->red, id+1->green, id+2->blue, id+3->alpha)
            var output_id = y*ww+Math.floor(x/3)*4;
            spR = Math.round((d[id + 0] + d[id + 4] + d[id + 8])/3);
            spG = Math.round((d[id + 1] + d[id + 5] + d[id + 9])/3);
            spB = Math.round((d[id + 2] + d[id + 6] + d[id + 10])/3);
            // console.log(d[id+0], d[id+1], d[id+2] + '|' + d[id+5], d[id+6], d[id+7] + '|' + d[id+9], d[id+10], d[id+11]);                        
            d[output_id] = spR;
            d[output_id+1] = spG;
            d[output_id+2] = spB;
            d[output_id+3] = 255; // alpha is always set to 255
        }
    }
    return imgData;
}

var subPixelText = function(ctx,text,x,y,fontHeight){

    var width = ctx.measureText(text).width + 12; // add some extra pixels
    var hOffset = Math.floor(fontHeight);

    var c = document.createElement("canvas");
    c.width  = width * 3; // scaling by 3
    c.height = fontHeight;
    c.ctx    = c.getContext("2d");    
    c.ctx.font = ctx.font;
    c.ctx.globalAlpha = ctx.globalAlpha;
    c.ctx.fillStyle = ctx.fillStyle;
    c.ctx.fontAlign = "left";
    c.ctx.setTransform(3,0,0,1,0,0); // scaling by 3
    c.ctx.imageSmoothingEnabled = false;    
    c.ctx.mozImageSmoothingEnabled = false; // (obsolete)
    c.ctx.webkitImageSmoothingEnabled = false;
    c.ctx.msImageSmoothingEnabled = false;
    c.ctx.oImageSmoothingEnabled = false; 
    // copy existing pixels to new canvas
    c.ctx.drawImage(ctx.canvas,x,y-hOffset,width,fontHeight,0,0,width,fontHeight);
    c.ctx.fillText(text,0,hOffset-3 /* (harcoded to -3 for letters like 'p', 'g', ..., could be improved) */); // draw the text 3 time the width
    // convert to sub pixels 
    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);
}
Ghislain
  • 21
  • 2