UNPKG

15.1 kBJavaScriptView Raw
1'use strict'
2
3let Font = require('css-font')
4let pick = require('pick-by-alias')
5let createRegl = require('regl')
6let createGl = require('gl-util/context')
7let WeakMap = require('es6-weak-map')
8let rgba = require('color-normalize')
9let fontAtlas = require('font-atlas')
10let pool = require('typedarray-pool')
11let parseRect = require('parse-rect')
12let isObj = require('is-plain-obj')
13let parseUnit = require('parse-unit')
14let px = require('to-px')
15let kerning = require('detect-kerning')
16let extend = require('object-assign')
17let metrics = require('font-measure')
18
19
20let shaderCache = new WeakMap
21
22
23class GlText {
24 constructor (o) {
25 if (isRegl(o)) {
26 o = {regl: o}
27 this.gl = o.regl._gl
28 }
29 else {
30 this.gl = createGl(o)
31 }
32
33 let shader = shaderCache.get(this.gl)
34
35 if (!shader) {
36 let regl = o.regl || createRegl({ gl: this.gl })
37
38 // draw texture method
39 let draw = regl({
40 vert: `
41 precision highp float;
42 attribute float width, charOffset, char;
43 uniform float fontSize, charStep, em, align, baseline;
44 uniform vec4 viewport;
45 uniform vec2 position, atlasSize, atlasDim, scale, translate, offset;
46 varying vec2 charCoord, charId;
47 varying float charWidth;
48 void main () {
49 vec2 offset = floor(em * (vec2(align + charOffset, baseline) + offset)) / (viewport.zw * scale.xy);
50 vec2 position = (position + translate) * scale;
51 position += offset * scale;
52
53 ${ GlText.normalViewport ? 'position.y = 1. - position.y;' : '' }
54
55 charCoord = position * viewport.zw + viewport.xy;
56
57 gl_Position = vec4(position * 2. - 1., 0, 1);
58
59 gl_PointSize = charStep;
60
61 charId.x = mod(char, atlasDim.x);
62 charId.y = floor(char / atlasDim.x);
63
64 charWidth = width * em;
65 }`,
66
67 frag: `
68 precision highp float;
69 uniform sampler2D atlas;
70 uniform vec4 color;
71 uniform float fontSize, charStep;
72 uniform vec2 atlasSize;
73 varying vec2 charCoord, charId;
74 varying float charWidth;
75
76 float lightness(vec4 color) {
77 return color.r * 0.299 + color.g * 0.587 + color.b * 0.114;
78 }
79
80 void main () {
81 float halfCharStep = floor(charStep * .5 + .5);
82 vec2 uv = floor(gl_FragCoord.xy - charCoord + halfCharStep);
83 uv.y = charStep - uv.y;
84
85 // ignore points outside of character bounding box
86 float halfCharWidth = ceil(charWidth * .5);
87 if (floor(uv.x) > halfCharStep + halfCharWidth ||
88 floor(uv.x) < halfCharStep - halfCharWidth) return;
89
90 uv += charId * charStep;
91 uv = uv / atlasSize;
92
93 vec4 fontColor = color;
94 vec4 mask = texture2D(atlas, uv);
95
96 float maskY = lightness(mask);
97 // float colorY = lightness(fontColor);
98 fontColor.a *= maskY;
99
100 // fontColor.a += .1;
101
102 // antialiasing, see yiq color space y-channel formula
103 // fontColor.rgb += (1. - fontColor.rgb) * (1. - mask.rgb);
104
105 gl_FragColor = fontColor;
106 }`,
107
108 blend: {
109 enable: true,
110 color: [0,0,0,1],
111
112 func: {
113 srcRGB: 'src alpha',
114 dstRGB: 'one minus src alpha',
115 srcAlpha: 'one minus dst alpha',
116 dstAlpha: 'one'
117 }
118 },
119 stencil: {enable: false},
120 depth: {enable: false},
121
122 attributes: {
123 char: regl.this('charBuffer'),
124 charOffset: {
125 offset: 4,
126 stride: 8,
127 buffer: regl.this('sizeBuffer')
128 },
129 width: {
130 offset: 0,
131 stride: 8,
132 buffer: regl.this('sizeBuffer')
133 }
134 },
135 uniforms: {
136 position: regl.this('position'),
137 atlasSize: function () {
138 return [this.fontAtlas.width, this.fontAtlas.height]
139 },
140 atlasDim: function () {
141 return [this.fontAtlas.cols, this.fontAtlas.rows]
142 },
143 fontSize: regl.this('fontSize'),
144 em: function () { return this.fontSize },
145 atlas: function () { return this.fontAtlas.texture },
146 viewport: regl.this('viewportArray'),
147 color: regl.this('color'),
148 scale: regl.this('scale'),
149 align: regl.this('alignOffset'),
150 baseline: regl.this('baselineOffset'),
151 translate: regl.this('translate'),
152 charStep: function () {
153 return this.fontAtlas.step
154 },
155 offset: regl.this('offset')
156 },
157 primitive: 'points',
158 count: regl.this('count'),
159 viewport: regl.this('viewport')
160 })
161
162 // per font-size atlas
163 let atlas = {}
164
165 shader = { regl, draw, atlas }
166
167 shaderCache.set(this.gl, shader)
168 }
169
170 this.render = shader.draw.bind(this)
171 this.regl = shader.regl
172 this.canvas = this.gl.canvas
173 this.shader = shader
174
175 this.charBuffer = this.regl.buffer({ type: 'uint8', usage: 'stream' })
176 this.sizeBuffer = this.regl.buffer({ type: 'float', usage: 'stream' })
177
178 this.update(isObj(o) ? o : {})
179 }
180
181 update (o) {
182 if (typeof o === 'string') o = { text: o }
183 else if (!o) return
184
185 // FIXME: make this a static transform or more general approact
186 o = pick(o, {
187 font: 'font fontFace fontface typeface cssFont css-font family fontFamily',
188 fontSize: 'fontSize fontsize size font-size',
189 text: 'text value symbols',
190 align: 'align alignment textAlign textbaseline',
191 baseline: 'baseline textBaseline textbaseline',
192 direction: 'dir direction textDirection',
193 color: 'color colour fill fill-color fillColor textColor textcolor',
194 kerning: 'kerning kern',
195 range: 'range dataBox',
196 viewport: 'vp viewport viewBox viewbox viewPort',
197 opacity: 'opacity alpha transparency visible visibility opaque',
198 offset: 'offset padding shift indent indentation'
199 }, true)
200
201
202 if (o.opacity != null) this.opacity = parseFloat(o.opacity)
203
204 // FIXME: mb add multiple colors?
205 if (o.color) {
206 this.color = rgba(o.color)
207 }
208
209 if (o.viewport != null) {
210 this.viewport = parseRect(o.viewport)
211
212 if (GlText.normalViewport) {
213 this.viewport.y = this.canvas.height - this.viewport.y - this.viewport.height
214 }
215
216 this.viewportArray = [this.viewport.x, this.viewport.y, this.viewport.width, this.viewport.height]
217
218 }
219 if (this.viewport == null) {
220 this.viewport = {
221 x: 0, y: 0,
222 width: this.gl.drawingBufferWidth,
223 height: this.gl.drawingBufferHeight
224 }
225 this.viewportArray = [this.viewport.x, this.viewport.y, this.viewport.width, this.viewport.height]
226 }
227
228 if (o.kerning != null) this.kerning = o.kerning
229
230 if (o.offset != null) {
231 if (typeof o.offset === 'number') this.offset = [o.offset, 0]
232 else this.offset = o.offset
233
234 if (!GlText.normalViewport) {
235 this.offset[1] *= -1
236 }
237 }
238
239 if (o.direction) this.direction = o.direction
240
241 if (o.position) this.position = o.position
242
243 if (o.range) {
244 this.range = o.range
245 this.scale = [1 / (o.range[2] - o.range[0]), 1 / (o.range[3] - o.range[1])]
246 this.translate = [-o.range[0], -o.range[1]]
247 }
248 if (o.scale) this.scale = o.scale
249 if (o.translate) this.translate = o.translate
250
251 // default scale corresponds to viewport
252 if (!this.scale) this.scale = [1 / this.viewport.width, 1 / this.viewport.height]
253
254 if (!this.translate) this.translate = [0, 0]
255
256 if (!this.font && !o.font) o.font = GlText.baseFontSize + 'px sans-serif'
257
258 // normalize font caching string
259 let newFont = false, newFontSize = false
260
261 // normalize font
262 if (typeof o.font === 'string') {
263 try {
264 o.font = Font.parse(o.font)
265 } catch (e) {
266 o.font = Font.parse(GlText.baseFontSize + 'px ' + o.font)
267 }
268 }
269 else if (o.font) o.font = Font.parse(Font.stringify(o.font))
270
271 // obtain new font data
272 if (o.font) {
273 let baseString = Font.stringify({
274 size: GlText.baseFontSize,
275 family: o.font.family,
276 stretch: o.font.stretch,
277 variant: o.font.variant,
278 weight: o.font.weight,
279 style: o.font.style
280 })
281
282 let unit = parseUnit(o.font.size)
283 let fs = Math.round(unit[0] * px(unit[1]))
284 if (fs !== this.fontSize) {
285 newFontSize = true
286 this.fontSize = fs
287 }
288
289 // calc new font metrics/atlas
290 if (!this.font || baseString != this.font.baseString) {
291 newFont = true
292
293 // obtain font cache or create one
294 this.font = GlText.fonts[baseString]
295 if (!this.font) {
296 let family = o.font.family.join(', ')
297 this.font = {
298 baseString,
299
300 // typeface
301 family,
302 weight: o.font.weight,
303 stretch: o.font.stretch,
304 style: o.font.style,
305 variant: o.font.variant,
306
307 // widths of characters
308 width: {},
309
310 // kernin pairs offsets
311 kerning: {},
312
313 metrics: metrics(family, {
314 origin: 'top',
315 fontSize: GlText.baseFontSize,
316 fontStyle: `${o.font.style} ${o.font.variant} ${o.font.weight} ${o.font.stretch}`
317 })
318 }
319
320 GlText.fonts[baseString] = this.font
321 }
322 }
323 }
324
325 if (o.fontSize) {
326 let unit = parseUnit(o.fontSize)
327 let fs = Math.round(unit[0] * px(unit[1]))
328
329 if (fs != this.fontSize) {
330 newFontSize = true
331 this.fontSize = fs
332 }
333 }
334
335 if (newFont || newFontSize) {
336 this.fontString = Font.stringify({
337 size: this.fontSize,
338 family: this.font.family,
339 stretch: this.font.stretch,
340 variant: this.font.variant,
341 weight: this.font.weight,
342 style: this.font.style
343 })
344
345 // calc new font size atlas
346 this.fontAtlas = this.shader.atlas[this.fontString]
347
348 if (!this.fontAtlas) {
349 let metrics = this.font.metrics
350
351 this.shader.atlas[this.fontString] =
352 this.fontAtlas = {
353 // even step is better for rendered characters
354 step: Math.ceil(this.fontSize * metrics.bottom * .5) * 2,
355 em: this.fontSize,
356 cols: 0,
357 rows: 0,
358 height: 0,
359 width: 0,
360 chars: [],
361 ids: {},
362 texture: this.regl.texture()
363 }
364 }
365
366 // bump atlas characters
367 if (o.text == null) o.text = this.text
368 }
369
370 // calculate offsets for the new font/text
371 if (o.text != null || newFont) {
372 // FIXME: ignore spaces
373 this.text = o.text
374 this.count = o.text.length
375
376 let newAtlasChars = []
377
378 // detect & measure new characters
379 GlText.atlasContext.font = this.font.baseString
380
381 for (let i = 0; i < this.text.length; i++) {
382 let char = this.text.charAt(i)
383
384 if (this.fontAtlas.ids[char] == null) {
385 this.fontAtlas.ids[char] = this.fontAtlas.chars.length
386 this.fontAtlas.chars.push(char)
387 newAtlasChars.push(char)
388 }
389
390 if (this.font.width[char] == null) {
391 this.font.width[char] = GlText.atlasContext.measureText(char).width / GlText.baseFontSize
392
393 // measure kerning pairs for the new character
394 if (this.kerning) {
395 let pairs = []
396 for (let baseChar in this.font.width) {
397 pairs.push(baseChar + char, char + baseChar)
398 }
399 extend(this.font.kerning, kerning(this.font.family, {
400 pairs
401 }))
402 }
403 }
404 }
405
406 // populate text/offset buffers
407 // as [charWidth, offset, charWidth, offset...]
408 // that is in em units since font-size can change often
409 let charIds = pool.mallocUint8(this.count)
410 let sizeData = pool.mallocFloat(this.count * 2)
411 for (let i = 0; i < this.count; i++) {
412 let char = this.text.charAt(i)
413 let prevChar = this.text.charAt(i - 1)
414
415 charIds[i] = this.fontAtlas.ids[char]
416 sizeData[i * 2] = this.font.width[char]
417
418 if (i) {
419 let prevWidth = sizeData[i * 2 - 2]
420 let currWidth = sizeData[i * 2]
421 let prevOffset = sizeData[i * 2 - 1]
422 let offset = prevOffset + prevWidth * .5 + currWidth * .5;
423
424 if (this.kerning) {
425 let kerning = this.font.kerning[prevChar + char]
426 if (kerning) {
427 offset += kerning * 1e-3
428 }
429 }
430
431 sizeData[i * 2 + 1] = offset
432 }
433 else {
434 sizeData[1] = sizeData[0] * .5
435 }
436 }
437
438 if (this.count) {
439 this.textWidth = (sizeData[sizeData.length - 2] * .5 + sizeData[sizeData.length - 1])
440 } else {
441 this.textWidth = 0
442 }
443 this.alignOffset = alignOffset(this.align, this.textWidth)
444
445 this.charBuffer({data: charIds, type: 'uint8', usage: 'stream'})
446 this.sizeBuffer({data: sizeData, type: 'float', usage: 'stream'})
447 pool.freeUint8(charIds)
448 pool.freeFloat(sizeData)
449
450 // udpate font atlas and texture
451 if (newAtlasChars.length) {
452 // FIXME: insert metrics-based ratio here
453 let step = this.fontAtlas.step
454
455 let maxCols = Math.floor(GlText.maxAtlasSize / step)
456 let cols = Math.min(maxCols, this.fontAtlas.chars.length)
457 let rows = Math.ceil(this.fontAtlas.chars.length / cols)
458
459 let atlasWidth = cols * step
460 // let atlasHeight = Math.min(rows * step + step * .5, GlText.maxAtlasSize);
461 let atlasHeight = rows * step;
462
463 this.fontAtlas.width = atlasWidth
464 this.fontAtlas.height = atlasHeight;
465 this.fontAtlas.rows = rows
466 this.fontAtlas.cols = cols
467
468 this.fontAtlas.texture({
469 data: fontAtlas({
470 canvas: GlText.atlasCanvas,
471 font: this.fontString,
472 chars: this.fontAtlas.chars,
473 shape: [atlasWidth, atlasHeight],
474 step: [step, step]
475 })
476 })
477 }
478 }
479
480 if (o.align) {
481 this.align = o.align
482 this.alignOffset = alignOffset(this.align, this.textWidth)
483 }
484
485 if (this.baseline == null && o.baseline == null) {
486 o.baseline = 0
487 }
488
489 if (o.baseline != null) {
490 this.baseline = o.baseline
491 let m = this.font.metrics
492 let base = 0
493 base += m.bottom * .5
494 if (typeof this.baseline === 'number') {
495 base += (this.baseline - m.baseline)
496 }
497 else {
498 base += -m[this.baseline]
499 }
500 if (!GlText.normalViewport) base *= -1
501 this.baselineOffset = base
502 }
503 }
504
505 destroy () {
506 // TODO: count instances of atlases and destroy all on null
507 }
508}
509
510
511// defaults
512GlText.prototype.kerning = true
513GlText.prototype.color = [0, 0, 0, 1]
514GlText.prototype.position = [0, 0]
515GlText.prototype.translate = null
516GlText.prototype.scale = null
517GlText.prototype.font = null
518GlText.prototype.text = ''
519GlText.prototype.offset = [0, 0]
520
521
522// whether viewport should be top↓bottom 2d one (true) or webgl one (false)
523GlText.normalViewport = false
524
525// size of an atlas
526GlText.maxAtlasSize = 1024
527
528// font atlas canvas is singleton
529GlText.atlasCanvas = document.createElement('canvas')
530GlText.atlasContext = GlText.atlasCanvas.getContext('2d', {alpha: false})
531
532// font-size used for metrics, atlas step calculation
533GlText.baseFontSize = 64
534
535// fonts storage
536GlText.fonts = {}
537
538// max number of different font atlases/textures cached
539// FIXME: enable atlas size limitation via LRU
540// GlText.atlasCacheSize = 64
541
542function alignOffset (align, tw) {
543 if (typeof align === 'number') return align
544 switch (align) {
545 case 'right':
546 case 'end':
547 return -tw
548 case 'center':
549 case 'centre':
550 case 'middle':
551 return -tw * .5
552 }
553 return 0
554}
555
556function isRegl (o) {
557 return typeof o === 'function' &&
558 o._gl &&
559 o.prop &&
560 o.texture &&
561 o.buffer
562}
563
564
565module.exports = GlText