Created
August 18, 2022 12:13
-
-
Save bennadel/c09c7abb50260d75aa8db60caa211158 to your computer and use it in GitHub Desktop.
Rendering Wrapped Text To A Canvas In JavaScript
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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—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. — | |
Derek Zoolander | |
</p> | |
<button class="button"> | |
Render to Canvas | |
</button> | |
</div> | |
<div class="panels__panel"> | |
<h2> | |
Canvas—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