├── .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 | [](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 | [](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 |
--------------------------------------------------------------------------------