├── .gitignore ├── .npmignore ├── LICENSE.md ├── README.md ├── demo.js ├── index.html ├── index.js ├── lib ├── render.js └── util.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | test 7 | test.js 8 | demo/ 9 | .npmignore 10 | LICENSE.md -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2015 Matt DesLauriers 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 20 | OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # canvas-text 2 | 3 | [![experimental](http://badges.github.io/stability-badges/dist/experimental.svg)](http://github.com/badges/stability-badges) 4 | 5 | #### work in progress 6 | 7 | Easier Canvas2D text rendering. 8 | 9 | - multiline text with `\n` 10 | - word-wrapping 11 | - left/center/right alignment 12 | - inline styles 13 | - moar 14 | 15 | Demo: 16 | 17 | http://mattdesl.github.io/canvas-text 18 | 19 | This still has some bugs that is cutting off text in places. If you'd like to contribute, drop me a line in the issues. For now, the current version is unstable `0.x` and undocumented until bugs are smoothed out. 20 | 21 | ## Usage 22 | 23 | [![NPM](https://nodei.co/npm/canvas-text.png)](https://www.npmjs.com/package/canvas-text) 24 | 25 | to come 26 | 27 | ## License 28 | 29 | MIT, see [LICENSE.md](http://github.com/mattdesl/canvas-text/blob/master/LICENSE.md) for details. 30 | -------------------------------------------------------------------------------- /demo.js: -------------------------------------------------------------------------------- 1 | var createText = require('./') 2 | var getContext = require('get-canvas-context') 3 | var Promise = require('pinkie-promise') 4 | var dashLine = require('ctx-dashed-line') 5 | var assign = require('object-assign') 6 | var debounce = require('debounce') 7 | var lerp = require('lerp') 8 | var ease = require('eases/sine-in-out') 9 | 10 | var loop = require('canvas-loop') 11 | var context = getContext('2d') 12 | var canvas = context.canvas 13 | 14 | context.fillStyle = 'white' 15 | context.fillRect(0, 0, canvas.width, canvas.height) 16 | 17 | document.body.appendChild(canvas) 18 | 19 | var primary = { 20 | size: 42, 21 | fillStyle: 'hsl(100,0%,70%)', 22 | weight: 300 23 | } 24 | 25 | var chunks = [ 26 | assign({ 27 | text: ' Lorem ipsum dolor sit amet. ' 28 | }, primary), 29 | { 30 | text: 'hello, world.', 31 | size: 60, 32 | fillStyle: 'hsl(0,20%,50%)', 33 | underline: true, 34 | strokeStyle: 'rgba(0,0,0,0.15)', 35 | weight: 300 36 | }, 37 | assign({ 38 | text: ' Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' 39 | }, primary), 40 | { 41 | text: 'This text is drawn with canvas.', 42 | size: 32, 43 | fillStyle: 'hsl(0,0%,50%)', 44 | underline: false, 45 | strokeStyle: 'rgba(0,128,255,0.85)', 46 | weight: 700 47 | }, 48 | assign({ 49 | text: ' Sed sit amet diam molestie, tempus eros ut.' 50 | }, primary) 51 | ] 52 | 53 | var app = loop(canvas, { 54 | scale: window.devicePixelRatio 55 | }) 56 | 57 | var padding = 50 58 | var time = 0 59 | var animate = 0 60 | var text = createText(context, chunks, { 61 | lineSpacing: 15, 62 | family: '"Ubuntu", sans-serif', 63 | }) 64 | 65 | 66 | var onResize = debounce(function () { 67 | text.update(chunks, { width: app.shape[0] - padding*2 }) 68 | }, 10) 69 | 70 | app.on('tick', render) 71 | .on('resize', render) 72 | .on('resize', onResize) 73 | 74 | onResize() 75 | 76 | // try to use font loading to avoid FOUST 77 | loadFonts(text.fonts).then(function () { 78 | app.start() 79 | }, function (err) { 80 | throw err 81 | }) 82 | 83 | function loadFonts (fonts) { 84 | if (document.fonts && document.fonts.load) { 85 | return Promise.all(fonts.map(function (font) { 86 | return document.fonts.load(font) 87 | })) 88 | } else { 89 | return new Promise(function (resolve) { 90 | setTimeout(resolve, 500) 91 | }) 92 | } 93 | } 94 | 95 | function render (dt) { 96 | dt = dt || 0 97 | time += dt / 1000 98 | animate = ease(Math.sin(time) * 0.5 + 0.5) 99 | 100 | context.save() 101 | context.scale(app.scale, app.scale) 102 | 103 | var shape = app.shape 104 | context.clearRect(0, 0, shape[0], shape[1]) 105 | var x = padding 106 | var y = 25 107 | text.render(x, y, renderText) 108 | 109 | context.restore() 110 | } 111 | 112 | // we can tailor the chunk rendering to our application 113 | // e.g. animations, fancy underlines, etc 114 | function renderText (context, str, x, y, textWidth, lineHeight, attribute) { 115 | context.fillStyle = attribute.fillStyle 116 | 117 | if (attribute.underline) { 118 | context.beginPath() 119 | context.strokeStyle = attribute.strokeStyle || attribute.fillStyle 120 | context.lineWidth = lerp(0, 5, animate) 121 | context.lineCap = 'round' 122 | context.lineJoin = 'round' 123 | 124 | var offY = y + 13 125 | dashLine(context, 126 | [ x, offY + lineHeight ], 127 | [ x + textWidth, offY + lineHeight ], 128 | 8) 129 | context.stroke() 130 | } 131 | 132 | context.fillText(str, x, y + lineHeight) 133 | } 134 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | canvas-text-sdf 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var assign = require('object-assign') 2 | var util = require('./lib/util') 3 | var renderText = require('./lib/render') 4 | var wordWrap = require('word-wrapper') 5 | var dprop = require('dprop') 6 | 7 | var baseSettings = { 8 | baseline: 'alphabetic', 9 | align: 'left', 10 | fillStyle: '#000', 11 | style: 'normal', 12 | variant: 'normal', 13 | weight: 'normal', 14 | family: 'sans-serif', 15 | size: 32 16 | } 17 | 18 | module.exports = createStyledText 19 | function createStyledText (context, chunks, opts) { 20 | if (!context) { 21 | throw new TypeError('must specify a CanvasRenderingContext2D as first parameter') 22 | } 23 | 24 | var fullText = '' 25 | var maxLineWidth = 0 26 | var height = 0 27 | var data 28 | var lines = [] 29 | var fonts = [] 30 | var defaultOpts = assign({}, opts) 31 | 32 | var stlyedText = { 33 | render: render, 34 | update: update, 35 | layout: layout 36 | } 37 | 38 | // some read-only values 39 | Object.defineProperties(stlyedText, { 40 | lines: dprop(function () { 41 | return lines 42 | }), 43 | 44 | fonts: dprop(function () { 45 | return fonts 46 | }), 47 | 48 | width: dprop(function () { 49 | return maxLineWidth 50 | }), 51 | 52 | height: dprop(function () { 53 | return height 54 | }), 55 | }) 56 | 57 | update(chunks, opts) 58 | return stlyedText 59 | 60 | function update (newChunks, newOpts) { 61 | opts = assign({}, baseSettings, defaultOpts, newOpts) 62 | 63 | // accept array or single element for string data 64 | if (!Array.isArray(newChunks)) { 65 | newChunks = [ newChunks || '' ] 66 | } 67 | 68 | chunks = newChunks 69 | 70 | // run an initial layout by default 71 | if (opts.layout !== false) layout() 72 | } 73 | 74 | function layout () { 75 | // copy data to avoid mutating user objects 76 | data = chunks.map(function (attrib) { 77 | if (typeof attrib === 'string') { 78 | attrib = { text: attrib } 79 | } 80 | 81 | attrib = assign({}, opts, attrib) 82 | 83 | attrib.text = attrib.text || '' 84 | attrib.font = getFontStyle(attrib, opts) 85 | 86 | // approximate line height from pixel size 87 | if (typeof attrib.lineHeight === 'undefined') { 88 | attrib.lineHeight = getLineHeight(attrib.size) 89 | } 90 | 91 | return attrib 92 | }) 93 | 94 | fonts = data.map(function (attrib) { 95 | return attrib.font 96 | }) 97 | 98 | fullText = util.composeBuffer(data) 99 | lines = wordWrap.lines(fullText, { 100 | width: opts.width, 101 | mode: opts.wordWrap, 102 | measure: measure 103 | }) 104 | 105 | var lineSpacing = opts.lineSpacing || 0 106 | for (var i = lines.length - 1; i >= 0; i--) { 107 | var line = lines[i] 108 | line.height = util.getMaxAttrHeight(data, line.start, line.end) 109 | line.height += lineSpacing 110 | } 111 | 112 | maxLineWidth = lines.reduce(function (prev, line) { 113 | return Math.max(line.width, prev) 114 | }, 0) 115 | 116 | height = lines.reduce(function (prev, line) { 117 | return prev + line.height 118 | }, 0) 119 | } 120 | 121 | function render (originX, originY, renderFunc) { 122 | var cursorX = 0 123 | var cursorY = lines.length > 0 ? lines[0].height : 0 124 | 125 | for (var i = 0; i < lines.length; i++) { 126 | var line = lines[i] 127 | var start = line.start 128 | var end = line.end 129 | var lineWidth = line.width 130 | var maxAttrHeight = line.height 131 | 132 | while (start < end) { 133 | // find next attribute chunk for this string char 134 | var attribIdx = util.indexOfAttribute(data, start) 135 | if (attribIdx === -1) { 136 | break 137 | } 138 | 139 | // for each attribute in this range ... 140 | var attrib = data[attribIdx] 141 | var length = util.getBlockLength(attrib, start, end) 142 | 143 | var text = fullText.substring(start, start + length) 144 | var lineHeight = attrib.lineHeight 145 | 146 | var x = originX + cursorX 147 | var y = originY + cursorY - lineHeight 148 | if (opts.align === 'right') { 149 | x += (maxLineWidth - lineWidth) 150 | } else if (opts.align === 'center') { 151 | x += (maxLineWidth - lineWidth) / 2 152 | } 153 | 154 | util.setFontParams(context, attrib) 155 | var textWidth = context.measureText(text).width 156 | cursorX += textWidth 157 | 158 | if (renderFunc) { 159 | renderFunc(context, text, x, y, textWidth, lineHeight, attrib, i) 160 | } else { 161 | renderText(context, text, x, y, textWidth, lineHeight, attrib, i) 162 | } 163 | 164 | // skip to next chunk 165 | start += Math.max(1, length) 166 | } 167 | cursorX = 0 168 | 169 | var next = lines[i + 1] 170 | if (next) { 171 | cursorY += next.height 172 | } else { 173 | cursorY += maxAttrHeight 174 | } 175 | } 176 | } 177 | 178 | function measure (text, start, end, width) { 179 | var availableGlyphs = 0 180 | var first = start 181 | var totalWidth = 0 182 | 183 | while (start < end) { 184 | // find next attribute chunk for this string char 185 | var attribIdx = util.indexOfAttribute(data, start) 186 | if (attribIdx === -1) { 187 | break 188 | } 189 | 190 | // for each attribute in this range ... 191 | var attrib = data[attribIdx] 192 | 193 | var result = util.computeAttributeGlyphs(context, fullText, totalWidth, attrib, start, end, width) 194 | availableGlyphs += result.available 195 | totalWidth += result.width 196 | 197 | // skip to next attribute 198 | start = attrib.index + attrib.text.length 199 | } 200 | 201 | return { 202 | width: totalWidth, 203 | start: first, 204 | end: first + availableGlyphs 205 | } 206 | } 207 | } 208 | 209 | function getFontStyle (opt, defaults) { 210 | var style = opt.style || defaults.style 211 | var variant = opt.variant || defaults.variant 212 | var weight = String(opt.weight || defaults.weight) 213 | var family = opt.family || defaults.family 214 | var fontSize = typeof opt.size === 'number' ? opt.size : defaults.size 215 | var fontStyle = [ style, variant, weight, fontSize + 'px', family ].join(' ') 216 | return fontStyle 217 | } 218 | 219 | function getLineHeight (fontSize) { 220 | return fontSize + 1 221 | } 222 | -------------------------------------------------------------------------------- /lib/render.js: -------------------------------------------------------------------------------- 1 | module.exports = renderText 2 | 3 | function renderText (context, text, x, y, textWidth, lineHeight, attribute) { 4 | context.fillStyle = attribute.fillStyle 5 | context.fillText(text, x, y + lineHeight) 6 | } 7 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | module.exports.getBlockLength = getBlockLength 2 | module.exports.computeAttributeGlyphs = computeAttributeGlyphs 3 | module.exports.setFontParams = setFontParams 4 | module.exports.indexOfAttribute = indexOfAttribute 5 | module.exports.composeBuffer = composeBuffer 6 | module.exports.getMaxAttrHeight = getMaxAttrHeight 7 | 8 | function setFontParams (context, opt) { 9 | context.textAlign = 'left' 10 | context.textBaseline = opt.baseline || 'alphabetic' 11 | context.font = opt.font 12 | } 13 | 14 | function getBlockLength (attrib, start, end) { 15 | var offset = Math.max(0, start - attrib.index) 16 | return Math.min(attrib.text.length - offset, end - start) 17 | } 18 | 19 | function computeAttributeGlyphs (context, fullText, prevWidth, attrib, start, end, width) { 20 | var length = getBlockLength(attrib, start, end) 21 | 22 | // determine how many chars in this chunk can be shown 23 | setFontParams(context, attrib) 24 | for (var off = 0; off < length; off++) { 25 | var substr = fullText.substring(start, start + (length - off)) 26 | var newWidth = context.measureText(substr).width 27 | if ((prevWidth + newWidth) <= width) { 28 | return { available: substr.length, width: newWidth } 29 | } 30 | } 31 | return { available: 0, width: 0 } 32 | } 33 | 34 | function indexOfAttribute (data, charIndex) { 35 | // find the first attribute at this character index 36 | for (var i = 0; i < data.length; i++) { 37 | var attrib = data[i] 38 | var text = attrib.text 39 | var length = text.length 40 | if (charIndex >= attrib.index && charIndex < attrib.index + length) { 41 | return i 42 | } 43 | } 44 | return -1 45 | } 46 | 47 | function composeBuffer (data) { 48 | var buffer = '' 49 | var previous = 0 50 | data.forEach(function (attrib) { 51 | var text = attrib.text 52 | buffer += text 53 | attrib.index = previous 54 | previous = attrib.index + text.length 55 | }) 56 | return buffer 57 | } 58 | 59 | function getMaxAttrHeight (data, start, end) { 60 | var maxAttrHeight = 0 61 | // first we need to compute max attribute height for this line 62 | while (start < end) { 63 | // find next attribute chunk for this string char 64 | var attribIdx = indexOfAttribute(data, start) 65 | if (attribIdx === -1) { 66 | break 67 | } 68 | 69 | // for each attribute in this range ... 70 | var attrib = data[attribIdx] 71 | maxAttrHeight = Math.max(attrib.lineHeight, maxAttrHeight) 72 | start = attrib.index + attrib.text.length 73 | } 74 | return maxAttrHeight 75 | } 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "canvas-text", 3 | "version": "0.0.1", 4 | "description": "better Canvas2D text rendering", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Matt DesLauriers", 9 | "email": "dave.des@gmail.com", 10 | "url": "https://github.com/mattdesl" 11 | }, 12 | "dependencies": { 13 | "dprop": "^1.0.0", 14 | "object-assign": "^4.0.1", 15 | "word-wrapper": "^1.0.7" 16 | }, 17 | "devDependencies": { 18 | "ctx-dashed-line": "^1.0.0", 19 | "debounce": "^1.0.0", 20 | "browserify": "^11.1.0", 21 | "budo": "^5.0.0-beta4", 22 | "canvas-fit": "^1.5.0", 23 | "canvas-loop": "^1.0.4", 24 | "eases": "^1.0.6", 25 | "garnish": "^3.2.0", 26 | "get-canvas-context": "^1.0.1", 27 | "lerp": "^1.0.3", 28 | "pinkie-promise": "^1.0.0", 29 | "raf-loop": "^1.1.3", 30 | "uglify-js": "^2.4.24" 31 | }, 32 | "scripts": { 33 | "start": "budo demo.js:bundle.js --live | garnish", 34 | "build": "browserify demo.js | uglifyjs -cm > bundle.js" 35 | }, 36 | "keywords": [ 37 | "sdf", 38 | "text", 39 | "atlas", 40 | "signed", 41 | "distance", 42 | "field", 43 | "alpha", 44 | "test", 45 | "testing", 46 | "smooth", 47 | "font", 48 | "webgl", 49 | "texts", 50 | "label", 51 | "labels", 52 | "fonts", 53 | "word" 54 | ], 55 | "repository": { 56 | "type": "git", 57 | "url": "git://github.com/mattdesl/canvas-text.git" 58 | }, 59 | "homepage": "https://github.com/mattdesl/canvas-text", 60 | "bugs": { 61 | "url": "https://github.com/mattdesl/canvas-text/issues" 62 | } 63 | } 64 | --------------------------------------------------------------------------------