Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created August 18, 2022 12:13
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bennadel/c09c7abb50260d75aa8db60caa211158 to your computer and use it in GitHub Desktop.
Save bennadel/c09c7abb50260d75aa8db60caa211158 to your computer and use it in GitHub Desktop.
Rendering Wrapped Text To A Canvas In JavaScript
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>
Rendering Wrapped Text To A Canvas In JavaScript
</title>
<link rel="stylesheet" type="text/css" href="./main.css" />
</head>
<body>
<h1>
Rendering Wrapped Text To A Canvas In JavaScript
</h1>
<div class="panels">
<div class="panels__panel">
<h2>
P&mdash;Element
</h2>
<p class="sample">
I'm pretty sure there's a lot more to life than being really, really,
ridiculously good looking. And I plan on finding out what that is. &mdash;
Derek Zoolander
</p>
<button class="button">
Render to Canvas
</button>
</div>
<div class="panels__panel">
<h2>
Canvas&mdash;Element
</h2>
<canvas class="canvas">
<!-- Sample text will be rendered here as multiple lines of text. -->
</canvas>
</div>
</div>
<script type="text/javascript">
var sample = document.querySelector( ".sample" );
var button = document.querySelector( ".button" );
var canvas = document.querySelector( ".canvas" );
var context = canvas.getContext( "2d" );
// When the user clicks the button, render the text to the canvas.
button.addEventListener( "click", renderSampleNodeToCanvas );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
/**
* I render the text-content of the sample element to the canvas.
*/
function renderSampleNodeToCanvas() {
// The canvas element doesn't encode the concept of line-wrapping for text. As
// such, when we want to render wrapped text to the canvas, we have to perform
// the calculations ourselves; and then, draw each line, in turn, to the
// canvas at the appropriate offset. To do this, we're going to extract the
// rendered lines of text from the source DOM node (using the code from
// yesterday's demo).
var lines = extractLinesFromTextNode( sample.firstChild );
// Let's also extract the run-time styles of the text.
// --
// CAUTION: For this demo, I'm assuming that everything about the font is
// defined in PIXELS (font-size, line-height). This keeps everything as simple
// as possible (and somewhat within my skill-set). Also, Canvas has no sense
// of letter-spacing, so we're assuming the font has a natural letter-spacing.
var styles = getElementTextStyles( sample );
var box = getElementBox( sample );
// Resize the canvas to match text container.
canvas.setAttribute( "width", box.width );
canvas.setAttribute( "height", box.height );
// Set the canvas fill styles to match the source text styles.
context.fillStyle = styles.color;
context.textBaseline = "top";
context.font = ( styles.fontWeight + " " + styles.fontSize + "px " + styles.fontFamily );
// Each line of text has to be rendered individually, with the vertical offset
// being manually set on each rendering. To help center the text within the
// line-height, we're going to add some initial offset to the Y-coordinate.
// --
// CAUTION: This is not a consistent cross-browser solution; but, that goes
// beyond the scope of this post (and my current skill-set).
var offsetY = ( ( styles.lineHeight - styles.fontSize ) / 2 );
lines.forEach(
function iterator( line, i ) {
context.fillText( line, 0, ( ( i * styles.lineHeight ) + offsetY ) );
}
);
}
/**
* I get the bounding box of the given element.
*/
function getElementBox( element ) {
var rawBox = element.getBoundingClientRect();
return({
top: rawBox.y,
left: rawBox.x,
width: rawBox.width,
height: rawBox.height
});
}
/**
* I get the runtime CSS properties for the given element text.
*
* CAUTION: Everything here is assumed to be PIXELS for the demo.
*/
function getElementTextStyles( element ) {
var rawStyles = window.getComputedStyle( element );
return({
color: rawStyles[ "color" ],
fontSize: parseInt( rawStyles[ "font-size" ], 10 ),
fontFamily: rawStyles[ "font-family" ],
fontWeight: rawStyles[ "font-weight" ],
lineHeight: parseInt( rawStyles[ "line-height" ], 10 )
});
}
/**
* I extract the visually rendered lines of text from the given textNode as it
* exists in the document at this very moment. Meaning, it returns the lines of
* text as seen by the user.
*/
function extractLinesFromTextNode( textNode ) {
if ( textNode.nodeType !== 3 ) {
throw( new Error( "Lines can only be extracted from text nodes." ) );
}
// BECAUSE SAFARI: None of the "modern" browsers seem to care about the actual
// layout of the underlying markup. However, Safari seems to create range
// rectangles based on the physical structure of the markup (even when it
// makes no difference in the rendering of the text). As such, let's rewrite
// the text content of the node to REMOVE SUPERFLUOS WHITE-SPACE. This will
// allow Safari's .getClientRects() to work like the other modern browsers.
textNode.textContent = collapseWhiteSpace( textNode.textContent );
// A Range represents a fragment of the document which contains nodes and
// parts of text nodes. One thing that's really cool about a Range is that we
// can access the bounding boxes that contain the contents of the Range. By
// incrementally adding characters - from our text node - into the range, and
// then looking at the Range's client rectangles, we can determine which
// characters belong in which rendered line.
var textContent = textNode.textContent;
var range = document.createRange();
var lines = [];
var lineCharacters = [];
// Iterate over every character in the text node.
for ( var i = 0 ; i < textContent.length ; i++ ) {
// Set the range to span from the beginning of the text node up to and
// including the current character (offset).
range.setStart( textNode, 0 );
range.setEnd( textNode, ( i + 1 ) );
// At this point, the Range's client rectangles will include a rectangle
// for each visually-rendered line of text. Which means, the last
// character in our Range (the current character in our for-loop) will be
// the last character in the last line of text (in our Range). As such, we
// can use the current rectangle count to determine the line of text.
var lineIndex = ( range.getClientRects().length - 1 );
// If this is the first character in this line, create a new buffer for
// this line.
if ( ! lines[ lineIndex ] ) {
lines.push( lineCharacters = [] );
}
// Add this character to the currently pending line of text.
lineCharacters.push( textContent.charAt( i ) );
}
// At this point, we have an array (lines) of arrays (characters). Let's
// collapse the character buffers down into a single text value.
lines = lines.map(
function operator( characters ) {
return( collapseWhiteSpace( characters.join( "" ) ) );
}
);
return( lines );
}
/**
* I normalize the white-space in the given value such that the amount of white-
* space matches the rendered white-space (browsers collapse strings of white-space
* down to single space character, visually, and this is just updating the text to
* match that behavior).
*/
function collapseWhiteSpace( value ) {
return( value.trim().replace( /\s+/g, " " ) );
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment