Search code examples
javascriptnode.jscanvashtml5-canvasnode-canvas

Center Text with Multi Lines on Canvas in NodeJS


I want to center text on a canvas. Horizontally seems fine, but vertical is still a problem: I cannot figure out how to programmatically do this.

I was previously able to do this with 1 line, but now I'd like to get it to work for multiple lines of text.

The image right now looks like this:

enter image description here

the text should be positioned a bit higher, or am I mistaken?

enter image description here

Here is the code:


const fs = require('fs')
const {  createCanvas } = require('canvas')


const width = 2000;
const height = 2000;

const canvas = createCanvas(width, height)
const context = canvas.getContext('2d')

context.fillStyle = '#edf4ff'
context.fillRect(0, 0, width, height)


context.textAlign = 'center'
context.textBaseline = 'middle';
context.fillStyle = '#002763'


const fontSizeUsed = drawMultilineText(
    context,
    "This is yet another test",
    {
        rect: {
            x: 1000,
            y: 0,
            width: 2000,
            height: 2000 
        },
        font: 'Arial',
        verbose: true,
        lineHeight: 1,
        minFontSize: 100,
        maxFontSize: 200
      }
)

const buffer = canvas.toBuffer('image/png')
  fs.writeFileSync('./image.png', buffer)

and the crucial function drawMultiLineText, that's supposed to align the text, is this one:

function drawMultilineText(ctx, text, opts) {

    // Default options
    if(!opts)
        opts = {}
    if (!opts.font)
        opts.font = 'sans-serif'
    if (typeof opts.stroke == 'undefined')
        opts.stroke = false
    if (typeof opts.verbose == 'undefined')
        opts.verbose = false
    if (!opts.rect)
        opts.rect = {
            x: 0,
            y: 0,
            width: ctx.canvas.width,
            height: ctx.canvas.height
        }
    if (!opts.lineHeight)
        opts.lineHeight = 1.1
    if (!opts.minFontSize)
        opts.minFontSize = 30
    if (!opts.maxFontSize)
        opts.maxFontSize = 100
    // Default log function is console.log - Note: if verbose il false, nothing will be logged anyway
    if (!opts.logFunction)
        opts.logFunction = function(message) { console.log(message) }


        const words = require('words-array')(text)
        if (opts.verbose) opts.logFunction('Text contains ' + words.length + ' words')
        var lines = []
        let y;  //New Line

    // Finds max font size  which can be used to print whole text in opts.rec
    for (var fontSize = opts.minFontSize; fontSize <= opts.maxFontSize; fontSize++) {

        // Line height
        var lineHeight = fontSize * opts.lineHeight

        // Set font for testing with measureText()
        ctx.font = ' ' + fontSize + 'px ' + opts.font

        // Start
        var x = opts.rect.x;
        y = fontSize; //modified line
        lines = []
        var line = ''

        // Cycles on words
        for (var word of words) {
            // Add next word to line
            var linePlus = line + word + ' '
            // If added word exceeds rect width...
            if (ctx.measureText(linePlus).width > (opts.rect.width)) {
                // ..."prints" (save) the line without last word
                lines.push({ text: line, x: x, y: y })
                // New line with ctx last word
                line = word + ' '
                y += lineHeight
            } else {
                // ...continues appending words
                line = linePlus
            }
        }

        // "Print" (save) last line
        lines.push({ text: line, x: x, y: y })

        // If bottom of rect is reached then breaks "fontSize" cycle
        if (y > opts.rect.height)
            break

  }
  
    if (opts.verbose) opts.logFunction("Font used: " + ctx.font);
    const offset = opts.rect.y + (opts.rect.height - y) / 2; //New line, calculates offset
    for (var line of lines)
            // Fill or stroke
            if (opts.stroke)
                    ctx.strokeText(line.text.trim(), line.x, line.y + offset) //modified line
            else
                    ctx.fillText(line.text.trim(), line.x, line.y + offset) //modified line
    
    // Returns font size
    return fontSize

}

I am not in a browser, I am using node.js.


Solution

  • You're right, the text in your first image should be positioned higher, as well.

    There are 3 issues in the code:

    1. setting the initial value of y to fontSize
    2. calculation of variable offset
    3. exiting for loop when canvas height is exceeded without going back to the previous fontSize (i.e. the last one fitting)

    Issue 1

    The initial value of y should be set to lineHeight as opposed to fontSize.

    Issue 2

    The calculation of variable offset is not reflecting the facts that a) the starting y coordinate for the 1st text line is lineHeight instead of 0 and b) there is a textBaseline set to middle. An example of an adapted offset calculation is in my code below.

    Issue 3

    Once the value of y exceeds the canvas height (condition y > opts.rect.height), there should be a step back to the previous fontSize. To solve this, new variables can be introduced to store the values from the previous (i.e. last fitting) iteration and used for this necessary 'step back'. (Those variables in my code below are lastFittingLines, lastFittingFont, lastFittingY and lastFittingLineHeight.)

    Example image:

    Example of image with vertically centered text

    Here's the modified code:

    const fs = require('fs')
    const { createCanvas } = require('canvas')
    
    
    const width = 2000;
    const height = 2000;
    
    const canvas = createCanvas(width, height)
    const context = canvas.getContext('2d')
    
    context.fillStyle = '#edf4ff'
    context.fillRect(0, 0, width, height)
    
    
    context.textAlign = 'center'
    context.textBaseline = 'middle';
    context.fillStyle = '#002763'
    
    
    const fontSizeUsed = drawMultilineText(
        context,
        "This is a text with multiple lines that is vertically centered as expected.",
        {
            rect: {
                x: 1000,
                y: 0,
                width: 2000,
                height: 2000
            },
            font: 'Arial',
            verbose: true,
            lineHeight: 1,
            minFontSize: 100,
            maxFontSize: 200
        }
    )
    
    const buffer = canvas.toBuffer('image/png')
    fs.writeFileSync('./image3.png', buffer)
    
    function drawMultilineText(ctx, text, opts) {
    
        // Default options
        if (!opts)
            opts = {}
        if (!opts.font)
            opts.font = 'sans-serif'
        if (typeof opts.stroke == 'undefined')
            opts.stroke = false
        if (typeof opts.verbose == 'undefined')
            opts.verbose = false
        if (!opts.rect)
            opts.rect = {
                x: 0,
                y: 0,
                width: ctx.canvas.width,
                height: ctx.canvas.height
            }
        if (!opts.lineHeight)
            opts.lineHeight = 1.1
        if (!opts.minFontSize)
            opts.minFontSize = 30
        if (!opts.maxFontSize)
            opts.maxFontSize = 100
        // Default log function is console.log - Note: if verbose il false, nothing will be logged anyway
        if (!opts.logFunction)
            opts.logFunction = function (message) { console.log(message) }
    
    
        const words = require('words-array')(text)
        if (opts.verbose) opts.logFunction('Text contains ' + words.length + ' words')
        var lines = []
        let y;  //New Line
    
        // Finds max font size  which can be used to print whole text in opts.rec
    
        
        let lastFittingLines;                       // declaring 4 new variables (addressing issue 3)
        let lastFittingFont;
        let lastFittingY;
        let lastFittingLineHeight;
        for (var fontSize = opts.minFontSize; fontSize <= opts.maxFontSize; fontSize++) {
    
            // Line height
            var lineHeight = fontSize * opts.lineHeight
    
            // Set font for testing with measureText()
            ctx.font = ' ' + fontSize + 'px ' + opts.font
    
            // Start
            var x = opts.rect.x;
            y = lineHeight; //modified line        // setting to lineHeight as opposed to fontSize (addressing issue 1)
            lines = []
            var line = ''
    
            // Cycles on words
    
           
            for (var word of words) {
                // Add next word to line
                var linePlus = line + word + ' '
                // If added word exceeds rect width...
                if (ctx.measureText(linePlus).width > (opts.rect.width)) {
                    // ..."prints" (save) the line without last word
                    lines.push({ text: line, x: x, y: y })
                    // New line with ctx last word
                    line = word + ' '
                    y += lineHeight
                } else {
                    // ...continues appending words
                    line = linePlus
                }
            }
    
            // "Print" (save) last line
            lines.push({ text: line, x: x, y: y })
    
            // If bottom of rect is reached then breaks "fontSize" cycle
                
            if (y > opts.rect.height)                                           
                break;
                
            lastFittingLines = lines;               // using 4 new variables for 'step back' (issue 3)
            lastFittingFont = ctx.font;
            lastFittingY = y;
            lastFittingLineHeight = lineHeight;
    
        }
    
        lines = lastFittingLines;                   // assigning last fitting values (issue 3)                    
        ctx.font = lastFittingFont;                                                                   
        if (opts.verbose) opts.logFunction("Font used: " + ctx.font);
        const offset = opts.rect.y - lastFittingLineHeight / 2 + (opts.rect.height - lastFittingY) / 2;     // modifying calculation (issue 2)
        for (var line of lines)
            // Fill or stroke
            if (opts.stroke)
                ctx.strokeText(line.text.trim(), line.x, line.y + offset) //modified line
            else
                ctx.fillText(line.text.trim(), line.x, line.y + offset) //modified line
    
        // Returns font size
        return fontSize
    }