Justified paragraphs

suggest change

Renders text as justified paragraphs. REQUIRES the example Justified text

Example render

Top paragraph has setting.compact = true and bottom false and line spacing is 1.2 rather than the default 1.5. Rendered by code usage example bottom of this example.


Example code

// Requires justified text extensions 
(function(){
   // code point A
   if(typeof CanvasRenderingContext2D.prototype.fillJustifyText !== "function"){
       throw new ReferenceError("Justified Paragraph extension missing requiered CanvasRenderingContext2D justified text extension");
   }
   var maxSpaceSize = 3; // Multiplier for max space size. If greater then no justificatoin applied
   var minSpaceSize = 0.5; // Multiplier for minimum space size   
   var compact = true; // if true then try and fit as many words as possible. If false then try to get the spacing as close as possible to normal
   var lineSpacing = 1.5; // space between lines
   const noJustifySetting = {  // This setting forces justified text off. Used to render last line of paragraph.
       minSpaceSize : 1,
       maxSpaceSize : 1,
   }

   // Parse vet and set settings object.
   var justifiedTextSettings = function(settings){
       var min, max;
       var vetNumber = (num, defaultNum) => {
           num = num !== null && num !== null && !isNaN(num) ? num : defaultNum;
           return num < 0 ? defaultNum : num;
       }
       if(settings === undefined || settings === null){ return; }
       compact = settings.compact === true ? true : settings.compact === false ? false : compact;
       max = vetNumber(settings.maxSpaceSize, maxSpaceSize);
       min = vetNumber(settings.minSpaceSize, minSpaceSize);
       lineSpacing = vetNumber(settings.lineSpacing, lineSpacing);
       if(min > max){ return; }
       minSpaceSize = min;
       maxSpaceSize = max;
   }        
   var getFontSize = function(font){  // get the font size. 
       var numFind = /[0-9]+/;
       var number = numFind.exec(font)[0];
       if(isNaN(number)){
           throw new ReferenceError("justifiedPar Cant find font size");
       }
       return Number(number);
   }
   function justifiedPar(ctx, text, x, y, width, settings, stroke){
       var spaceWidth, minS, maxS, words, count, lines, lineWidth, lastLineWidth, lastSize, i, renderer, fontSize, adjSpace, spaces, word, lineWords, lineFound;
       spaceWidth = ctx.measureText(" ").width;
       minS = spaceWidth * minSpaceSize;
       maxS = spaceWidth * maxSpaceSize;
       words = text.split(" ").map(word => {  // measure all words.
           var w = ctx.measureText(word).width;                
           return {
               width : w,
               word : word,
           };
       });
       // count = num words, spaces = number spaces, spaceWidth normal space size
       // adjSpace new space size >= min size. useSize Resulting space size used to render
       count = 0;
       lines = [];
       // create lines by shifting words from the words array until the spacing is optimal. If compact
       // true then will true and fit as many words as possible. Else it will try and get the spacing as
       // close as possible to the normal spacing
       while(words.length > 0){
           lastLineWidth = 0;
           lastSize = -1;
           lineFound = false;
           // each line must have at least one word.
           word = words.shift();
           lineWidth = word.width;
           lineWords = [word.word];
           count = 0;
           while(lineWidth < width && words.length > 0){ // Add words to line
               word = words.shift();
               lineWidth += word.width;
               lineWords.push(word.word);
               count += 1;
               spaces = count - 1;
               adjSpace =  (width - lineWidth) / spaces;
               if(minS > adjSpace){  // if spacing less than min remove last word and finish line
                   lineFound = true;
                   words.unshift(word);
                   lineWords.pop();
               }else{
                   if(!compact){ // if compact mode 
                       if(adjSpace < spaceWidth){ // if less than normal space width
                           if(lastSize === -1){
                               lastSize = adjSpace;
                           }
                           // check if with last word on if its closer to space width
                           if(Math.abs(spaceWidth - adjSpace) < Math.abs(spaceWidth - lastSize)){
                               lineFound = true; // yes keep it
                           }else{
                               words.unshift(word);  // no better fit if last word removes
                               lineWords.pop();
                               lineFound = true;
                           }
                       }
                   }
               }
               lastSize = adjSpace; // remember spacing 
           }
           lines.push(lineWords.join(" ")); // and the line
       }
       // lines have been worked out get font size, render, and render all the lines. last
       // line may need to be rendered as normal so it is outside the loop.
       fontSize = getFontSize(ctx.font);
       renderer = stroke === true ? ctx.strokeJustifyText.bind(ctx) : ctx.fillJustifyText.bind(ctx);
       for(i = 0; i < lines.length - 1; i ++){
           renderer(lines[i], x, y, width, settings);
           y += lineSpacing * fontSize;
       }
       if(lines.length > 0){ // last line if left or start aligned for no justify
           if(ctx.textAlign === "left" || ctx.textAlign === "start"){
               renderer(lines[lines.length - 1], x, y, width, noJustifySetting);
               ctx.measureJustifiedText("", width, settings);
           }else{
               renderer(lines[lines.length - 1], x, y, width);
           }
       }
       // return details about the paragraph.
       y += lineSpacing * fontSize;
       return {
           nextLine : y,
           fontSize : fontSize,
           lineHeight : lineSpacing * fontSize,
       };
   }
   // define fill
   var fillParagraphText = function(text, x, y, width, settings){
       justifiedTextSettings(settings);
       settings = {
           minSpaceSize : minSpaceSize,
           maxSpaceSize : maxSpaceSize,
       };
       return justifiedPar(this, text, x, y, width, settings);
   }
   // define stroke
   var strokeParagraphText = function(text, x, y, width, settings){
       justifiedTextSettings(settings);
       settings = {
           minSpaceSize : minSpaceSize,
           maxSpaceSize : maxSpaceSize,
       };
       return justifiedPar(this, text, x, y, width, settings,true);
   }
   CanvasRenderingContext2D.prototype.fillParaText = fillParagraphText;
   CanvasRenderingContext2D.prototype.strokeParaText = strokeParagraphText;
})();
NOTE this extends the CanvasRenderingContext2D prototype. If you do not wish this to happen use the example Justified text to work out how to change this example to be part of the global namespace.
NOTE Will throw a ReferenceError if this example can not find the function CanvasRenderingContext2D.prototype.fillJustifyText

How to use

ctx.fillParaText(text, x, y, width, [settings]);
ctx.strokeParaText(text, x, y, width, [settings]);

See Justified text for details on arguments. Arguments between \[ and \] are optional.

The settings argument has two additional properties.

Properties missing from the settings object will default to their default values or to the last valid values. The properties will only be changed if the new values are valid. For compact valid values are only booleans true or false Truthy values are not considered valid.

Return object

The two functions return an object containing information to help you place the next paragraph. The object contains the following properties.

This example uses a simple algorithm that works one line at to time to find the best fit for a paragraph. This does not mean that it the best fit (rather the algorithm’s best) You may wish to improve the algorithm by creating a multi pass line algorithm over the generated lines. Moving words from the end of one line to the start of the next, or from the start back to the end. The best look is achieved when the spacing over the entire paragraph has the smallest variation and is the closest to the normal text spacing.

As this example is dependent on the Justified text example the code is very similar. You may wish to move the two into one function. Replace the function justifiedTextSettings in the other example with the one used in this example. Then copy all the rest of the code from this example into the anonymous function body of the Justified text example. You will no longer need to test for dependencies found at // Code point A It can be removed.


Usage example

ctx.font = "25px arial";
ctx.textAlign = "center"

var left = 10;
var center = canvas.width / 2;
var width = canvas.width-left*2;
var y = 20;
var size = 16;
var i = 0;
ctx.fillText("Justified paragraph examples.",center,y);
y+= 30;
ctx.font = "14px arial";
ctx.textAlign = "left"
// set para settings
var setting = {
    maxSpaceSize : 6,
    minSpaceSize : 0.5,
    lineSpacing : 1.2,
    compact : true,
}
// Show the left and right bounds.
ctx.strokeStyle = "red"
ctx.beginPath();
ctx.moveTo(left,y - size * 2);
ctx.lineTo(left, y + size * 15);
ctx.moveTo(canvas.width - left,y - size * 2);
ctx.lineTo(canvas.width - left, y + size * 15);
ctx.stroke();
ctx.textAlign = "left";
ctx.fillStyle = "black";

// Draw paragraph
var line = ctx.fillParaText(para, left, y, width, setting);  // settings is remembered    

// Next paragraph
y = line.nextLine + line.lineHeight;
setting.compact = false;
ctx.fillParaText(para, left, y, width, setting);
Note: For text aligned left or start the last line of tha paragraph will always have normal spacing. For all other alignments the last line is treated like all others.
Note: You can inset the start of the paragraph with spaces. Though this may not be consistent from paragraph to paragraph. It is always a good thing to learn what a function is doing and modifying it. An exercise would be to add a setting to the settings that indents the first line by a fixed amount. Hint the while loop will need to temporarily make the first word appear larger (+ indent) words[0].width += ? and then when rendering lines indent the first line.

Feedback about page:

Feedback:
Optional: your email if you want me to get back to you:



Table Of Contents