Text Layout and Justification on an HTML5 Canvas

The JavaScript code below, when run, will automatically split a simple poem across three different rectangles on an HTML5 canvas, and render the justified text.  To see it in action, copy it into an .html file and open that file in a web browser that runs JavaScript. Or, for an online version, visit http://thiscouldbebetter.neocities.org/textlayout.html.

The justification works by using the measureText() function of the HTML5 graphics context object to measure the width of lines of text.  It figures out how many words from the text string will fit on the current line, measures the width of those words with measureText(), subtracts that value from the maximum width, divides the difference by the number of characters (minus one), and inserts that amount of space between each character on the line to make the blocks of text nice, smooth, and rectangular.

In a better world, there would be built-in functions on the graphics context for all of this, but as of this writing the various interested parties are still trying to hash out those details.  Experience tells me that that could take years of glacially slow progress, or even sometimes regress.

This program was intended as a first step towards a more complete desktop publishing application, but I actually removed some of the objects associated with that effort from this post in the interest of clarity.

TextLayoutAndJustification

<html>
<body>
<script type='text/javascript'>

// main

function main()
{
	// A slightly adapted version of "Pippa's Song", by Robert Browning

	var content = 
		"The year's at the spring, "
		+ "and day's at the morn; "	 
		+ "morning's at seven; "
		+ "the hill-side's dew-pearl'd.\n"
		+ "\n"
		+ "The lark's on the wing; "
		+ "the snail's on the thorn; "	 
		+ "God's in His heaven — "
		+ "all's right with the world!\n";

	var margin = new Coords(20, 20);
	var font = new Font("sans-serif", 10);

	var document0 = new Document
	(
		"Document0",
		[
			new Zone(0, new Coords(10, 10), 	new Coords(150, 80), margin, 1, 	font, content),
			new Zone(1, new Coords(70, 110), 	new Coords(150, 80), margin, 2, 	font, null),
			new Zone(2, new Coords(130, 210), 	new Coords(150, 80), margin, null, 	font, null),
		]
	);

	var viewSizeInPixels = new Coords(300, 300);

	Globals.Instance.initialize
	(
		viewSizeInPixels,
		document0
	);
}

// classes

function Coords(x, y)
{
	this.x = x;
	this.y = y;
}
{
	Coords.prototype.clone = function()
	{
		return new Coords(this.x, this.y);
	}

	Coords.prototype.subtract = function(other)
	{
		this.x -= other.x;
		this.y -= other.y;

		return this;
	}
}

function DisplayHelper()
{}
{
	DisplayHelper.prototype.clear = function()
	{
		this.graphics.fillStyle = "Gray";
		this.graphics.fillRect
		(
			0, 0,
			this.viewSizeInPixels.x, this.viewSizeInPixels.y
		);
	}

	DisplayHelper.prototype.drawDocument = function(document)
	{
		this.clear();

		var zones = document.zones;

		for (var i = 0; i < zones.length; i++)
		{
			var zone = zones[i];
			this.drawZone(zone);
		}		
	}

	DisplayHelper.prototype.drawZone = function(zone)
	{
		var zonePos = zone.pos;
		var zoneSize = zone.size;
		var zoneMargin = zone.margin;
		var zoneSizeMinusMargin = zone.sizeMinusMargin;

		this.graphics.fillStyle = "White";
		this.graphics.fillRect
		(
			zonePos.x, zonePos.y,
			zoneSize.x, zoneSize.y
		);

		var contentAsLines = zone.contentAsLines;

		if (contentAsLines != null)
		{	
			var fontSizeY = zone.font.sizeInPixels;
			this.graphics.font = zone.font.toString();
			this.graphics.fillStyle = "Gray";

			for (var i = 0; i < contentAsLines.length; i++)
			{
				var contentLine = contentAsLines[i];

				var widthOfWhitespaceBetweenCharacters;

				if (contentLine.indexOf("\n") >= 0)
				{
					widthOfWhitespaceBetweenCharacters = 0;
				}
				else
				{
					contentLine = contentLine.trim();

					var widthOfLineBeforeJustification = this.graphics.measureText
					(
						contentLine
					).width;

					var widthOfWhitespaceBetweenCharacters = 
						(
							zoneSizeMinusMargin.x 
							- widthOfLineBeforeJustification
						)
						/ (contentLine.length - 1); 
				}

				contentLine = contentLine.trim();

				var charOffsetX = 0;

				for (var j = 0; j < contentLine.length; j++)
				{
					var contentChar = contentLine[j];			

					this.graphics.fillText
					(
						contentChar,
						zonePos.x + zoneMargin.x + charOffsetX,
 						zonePos.y + zoneMargin.y + fontSizeY * (i + 1)
					);

					var widthOfChar = this.graphics.measureText
					(
						contentChar
					).width;	

					charOffsetX += 
						widthOfChar
						+ widthOfWhitespaceBetweenCharacters;
				}
			}
		}
	}

	DisplayHelper.prototype.initialize = function(viewSizeInPixels)
	{
		this.viewSizeInPixels = viewSizeInPixels;

		var canvas = document.createElement("canvas");
		canvas.width = this.viewSizeInPixels.x;
		canvas.height = this.viewSizeInPixels.y;
		this.graphics = canvas.getContext("2d");

		document.body.appendChild(canvas);
	}
}

function Document(name, zones)
{
	this.name = name;

	this.zones = zones;

	for (var z = 0; z < this.zones.length; z++)
	{
		var zone = this.zones[z];
		this.zones["_" + zone.id] = zone;		
	}

	for (var z = 0; z < this.zones.length; z++)
	{
		var zone = this.zones[z];
		var zoneIDNext = zone.zoneIDNext;
		if (zoneIDNext != null)
		{
			zoneNext = this.zones["_" + zoneIDNext];
			zoneNext.zoneIDPrev = zone.id;
		}		
	}
}
{
	Document.prototype.initialize = function()
	{
		this.update();
	}

	Document.prototype.update = function()
	{
		for (var z = 0; z < this.zones.length; z++)
		{
			var zone = this.zones[z];
			zone.update(this);
		}

		Globals.Instance.displayHelper.drawDocument(this);
	}
}

function Font(name, sizeInPixels)
{
	this.name = name;
	this.sizeInPixels = sizeInPixels;
}
{
	Font.prototype.toString = function()
	{
		return "" + this.sizeInPixels + "px " + this.name;
	}
}

function Globals()
{}
{
	Globals.Instance = new Globals();

	Globals.prototype.initialize = function(viewSizeInPixels, document)
	{
		this.displayHelper = new DisplayHelper();
		this.displayHelper.initialize(viewSizeInPixels);

		this.document = document;
		this.document.initialize();
	}
}

function Zone(id, pos, size, margin, zoneIDNext, font, content)
{
	this.id = id;
	this.pos = pos;
	this.size = size;
	this.margin = margin;
	this.zoneIDNext = zoneIDNext;
	this.font = font;
	this.content = content;

	this.zoneIDPrev = null;

	this.sizeMinusMargin = this.size.clone().subtract
	(
		this.margin
	).subtract
	(
		this.margin
	);
}
{
	Zone.prototype.update = function(document)
	{
		if (this.content == null)
		{
			return;
		}

		var contentAsLines = [];

		var graphics = Globals.Instance.displayHelper.graphics;

		var fontSizeY = this.font.sizeInPixels;
		var charOffset = new Coords(0, 0);

		var lineCurrent = "";
		var wordCurrent = "";

		for (var i = 0; i < this.content.length; i++)
		{
			var contentChar = this.content[i];

			wordCurrent += contentChar;

			var widthOfContentChar = graphics.measureText
			(
				contentChar
			).width;
			charOffset.x += widthOfContentChar;

			if (contentChar == " ")
			{
				lineCurrent += wordCurrent;
				wordCurrent = "";
			}
			else if (contentChar == "\n")
			{
				lineCurrent += wordCurrent + "\n";
				wordCurrent = "";
				charOffset.x = this.sizeMinusMargin.x;
			}

			if (charOffset.x >= this.sizeMinusMargin.x)
			{
				charOffset.y += fontSizeY;

				if (charOffset.y >= this.sizeMinusMargin.y)
				{
					var zoneNextID = "_" + this.zoneIDNext;
					var zoneNext = document.zones[zoneNextID];
					if (zoneNext != null)
					{
						zoneNext.content = 
							wordCurrent 
							+ this.content.substr(i + 1);
						wordCurrent = "";
						zoneNext.update(document);
						break;
					}
				}

				contentAsLines.push(lineCurrent);
				lineCurrent = "" + wordCurrent;


				charOffset.x = graphics.measureText(wordCurrent).width;

				wordCurrent = "";
			}
		}

		lineCurrent += wordCurrent;
		contentAsLines.push(lineCurrent);

		this.contentAsLines = contentAsLines;
	}
}

// run

main();

</script>
</body>
</html>
This entry was posted in Uncategorized and tagged , , , , , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s