UNPKG

21.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')
18let flatten = require('flatten-vertex-data')
19let { nextPow2 } = require('bit-twiddle')
20
21let shaderCache = new WeakMap
22
23
24// Safari does not support font-stretch
25let isStretchSupported = false
26if (document.body) {
27 let el = document.body.appendChild(document.createElement('div'))
28 el.style.font = 'italic small-caps bold condensed 16px/2 cursive'
29 if (getComputedStyle(el).fontStretch) {
30 isStretchSupported = true
31 }
32 document.body.removeChild(el)
33}
34
35class GlText {
36 constructor (o) {
37 if (isRegl(o)) {
38 o = {regl: o}
39 this.gl = o.regl._gl
40 }
41 else {
42 this.gl = createGl(o)
43 }
44
45 this.shader = shaderCache.get(this.gl)
46
47 if (!this.shader) {
48 this.regl = o.regl || createRegl({ gl: this.gl })
49 }
50 else {
51 this.regl = this.shader.regl
52 }
53
54 this.charBuffer = this.regl.buffer({ type: 'uint8', usage: 'stream' })
55 this.sizeBuffer = this.regl.buffer({ type: 'float', usage: 'stream' })
56
57 if (!this.shader) {
58 this.shader = this.createShader()
59 shaderCache.set(this.gl, this.shader)
60 }
61
62 this.batch = []
63
64 // multiple options initial state
65 this.fontSize = []
66 this.font = []
67 this.fontAtlas = []
68
69 this.draw = this.shader.draw.bind(this)
70 this.render = function () {
71 // FIXME: add Safari regl report here:
72 // charBuffer and width just do not trigger
73 this.regl._refresh()
74 this.draw(this.batch)
75 }
76 this.canvas = this.gl.canvas
77
78 this.update(isObj(o) ? o : {})
79 }
80
81 createShader () {
82 let regl = this.regl
83
84 let draw = regl({
85 blend: {
86 enable: true,
87 color: [0,0,0,1],
88
89 func: {
90 srcRGB: 'src alpha',
91 dstRGB: 'one minus src alpha',
92 srcAlpha: 'one minus dst alpha',
93 dstAlpha: 'one'
94 }
95 },
96 stencil: {enable: false},
97 depth: {enable: false},
98
99 count: regl.prop('count'),
100 offset: regl.prop('offset'),
101 attributes: {
102 charOffset: {
103 offset: 4,
104 stride: 8,
105 buffer: regl.this('sizeBuffer')
106 },
107 width: {
108 offset: 0,
109 stride: 8,
110 buffer: regl.this('sizeBuffer')
111 },
112 char: regl.this('charBuffer'),
113 position: regl.this('position')
114 },
115 uniforms: {
116 atlasSize: (c, p) => [p.atlas.width, p.atlas.height],
117 atlasDim: (c, p) => [p.atlas.cols, p.atlas.rows],
118 atlas: (c, p) => p.atlas.texture,
119 charStep: (c, p) => p.atlas.step,
120 em: (c, p) => p.atlas.em,
121 color: regl.prop('color'),
122 opacity: regl.prop('opacity'),
123 viewport: regl.this('viewportArray'),
124 scale: regl.this('scale'),
125 align: regl.prop('align'),
126 baseline: regl.prop('baseline'),
127 translate: regl.this('translate'),
128 positionOffset: regl.prop('positionOffset')
129 },
130 primitive: 'points',
131 viewport: regl.this('viewport'),
132
133 vert: `
134 precision highp float;
135 attribute float width, charOffset, char;
136 attribute vec2 position;
137 uniform float fontSize, charStep, em, align, baseline;
138 uniform vec4 viewport;
139 uniform vec4 color;
140 uniform vec2 atlasSize, atlasDim, scale, translate, positionOffset;
141 varying vec2 charCoord, charId;
142 varying float charWidth;
143 varying vec4 fontColor;
144 void main () {
145 vec2 offset = floor(em * (vec2(align + charOffset, baseline)
146 + vec2(positionOffset.x, -positionOffset.y)))
147 / (viewport.zw * scale.xy);
148
149 vec2 position = (position + translate) * scale;
150 position += offset * scale;
151
152 charCoord = position * viewport.zw + viewport.xy;
153
154 gl_Position = vec4(position * 2. - 1., 0, 1);
155
156 gl_PointSize = charStep;
157
158 charId.x = mod(char, atlasDim.x);
159 charId.y = floor(char / atlasDim.x);
160
161 charWidth = width * em;
162
163 fontColor = color / 255.;
164 }`,
165
166 frag: `
167 precision highp float;
168 uniform sampler2D atlas;
169 uniform float fontSize, charStep, opacity;
170 uniform vec2 atlasSize;
171 uniform vec4 viewport;
172 varying vec4 fontColor;
173 varying vec2 charCoord, charId;
174 varying float charWidth;
175
176 float lightness(vec4 color) {
177 return color.r * 0.299 + color.g * 0.587 + color.b * 0.114;
178 }
179
180 void main () {
181 vec2 uv = gl_FragCoord.xy - charCoord + charStep * .5;
182 float halfCharStep = floor(charStep * .5 + .5);
183
184 // invert y and shift by 1px (FF expecially needs that)
185 uv.y = charStep - uv.y;
186
187 // ignore points outside of character bounding box
188 float halfCharWidth = ceil(charWidth * .5);
189 if (floor(uv.x) > halfCharStep + halfCharWidth ||
190 floor(uv.x) < halfCharStep - halfCharWidth) return;
191
192 uv += charId * charStep;
193 uv = uv / atlasSize;
194
195 vec4 color = fontColor;
196 vec4 mask = texture2D(atlas, uv);
197
198 float maskY = lightness(mask);
199 // float colorY = lightness(color);
200 color.a *= maskY;
201 color.a *= opacity;
202
203 // color.a += .1;
204
205 // antialiasing, see yiq color space y-channel formula
206 // color.rgb += (1. - color.rgb) * (1. - mask.rgb);
207
208 gl_FragColor = color;
209 }`
210 })
211
212 // per font-size atlas
213 let atlas = {}
214
215 return { regl, draw, atlas }
216 }
217
218 update (o) {
219 if (typeof o === 'string') o = { text: o }
220 else if (!o) return
221
222 // FIXME: make this a static transform or more general approact
223 o = pick(o, {
224 position: 'position positions coord coords coordinates',
225 font: 'font fontFace fontface typeface cssFont css-font family fontFamily',
226 fontSize: 'fontSize fontsize size font-size',
227 text: 'text texts chars characters value values symbols',
228 align: 'align alignment textAlign textbaseline',
229 baseline: 'baseline textBaseline textbaseline',
230 direction: 'dir direction textDirection',
231 color: 'color colour fill fill-color fillColor textColor textcolor',
232 kerning: 'kerning kern',
233 range: 'range dataBox',
234 viewport: 'vp viewport viewBox viewbox viewPort',
235 opacity: 'opacity alpha transparency visible visibility opaque',
236 offset: 'offset positionOffset padding shift indent indentation'
237 }, true)
238
239
240 if (o.opacity != null) {
241 if (Array.isArray(o.opacity)) {
242 this.opacity = o.opacity.map(o => parseFloat(o))
243 }
244 else {
245 this.opacity = parseFloat(o.opacity)
246 }
247 }
248
249 if (o.viewport != null) {
250 this.viewport = parseRect(o.viewport)
251
252 this.viewportArray = [this.viewport.x, this.viewport.y, this.viewport.width, this.viewport.height]
253
254 }
255 if (this.viewport == null) {
256 this.viewport = {
257 x: 0, y: 0,
258 width: this.gl.drawingBufferWidth,
259 height: this.gl.drawingBufferHeight
260 }
261 this.viewportArray = [this.viewport.x, this.viewport.y, this.viewport.width, this.viewport.height]
262 }
263
264 if (o.kerning != null) this.kerning = o.kerning
265
266 if (o.offset != null) {
267 if (typeof o.offset === 'number') o.offset = [o.offset, 0]
268
269 this.positionOffset = flatten(o.offset)
270 }
271
272 if (o.direction) this.direction = o.direction
273
274 if (o.range) {
275 this.range = o.range
276 this.scale = [1 / (o.range[2] - o.range[0]), 1 / (o.range[3] - o.range[1])]
277 this.translate = [-o.range[0], -o.range[1]]
278 }
279 if (o.scale) this.scale = o.scale
280 if (o.translate) this.translate = o.translate
281
282 // default scale corresponds to viewport
283 if (!this.scale) this.scale = [1 / this.viewport.width, 1 / this.viewport.height]
284
285 if (!this.translate) this.translate = [0, 0]
286
287 if (!this.font.length && !o.font) o.font = GlText.baseFontSize + 'px sans-serif'
288
289 // normalize font caching string
290 let newFont = false, newFontSize = false
291
292 // obtain new font data
293 if (o.font) {
294 (Array.isArray(o.font) ? o.font : [o.font]).forEach((font, i) => {
295 // normalize font
296 if (typeof font === 'string') {
297 try {
298 font = Font.parse(font)
299 } catch (e) {
300 font = Font.parse(GlText.baseFontSize + 'px ' + font)
301 }
302 }
303 else font = Font.parse(Font.stringify(font))
304
305 let baseString = Font.stringify({
306 size: GlText.baseFontSize,
307 family: font.family,
308 stretch: isStretchSupported ? font.stretch : undefined,
309 variant: font.variant,
310 weight: font.weight,
311 style: font.style
312 })
313
314 let unit = parseUnit(font.size)
315 let fs = Math.round(unit[0] * px(unit[1]))
316 if (fs !== this.fontSize[i]) {
317 newFontSize = true
318 this.fontSize[i] = fs
319 }
320
321 // calc new font metrics/atlas
322 if (!this.font[i] || baseString != this.font[i].baseString) {
323 newFont = true
324
325 // obtain font cache or create one
326 this.font[i] = GlText.fonts[baseString]
327 if (!this.font[i]) {
328 let family = font.family.join(', ')
329 let style = [font.style]
330 if (font.style != font.variant) style.push(font.variant)
331 if (font.variant != font.weight) style.push(font.weight)
332 if (isStretchSupported && font.weight != font.stretch) style.push(font.stretch)
333
334 this.font[i] = {
335 baseString,
336
337 // typeface
338 family,
339 weight: font.weight,
340 stretch: font.stretch,
341 style: font.style,
342 variant: font.variant,
343
344 // widths of characters
345 width: {},
346
347 // kernin pairs offsets
348 kerning: {},
349
350 metrics: metrics(family, {
351 origin: 'top',
352 fontSize: GlText.baseFontSize,
353 fontStyle: style.join(' ')
354 })
355 }
356
357 GlText.fonts[baseString] = this.font[i]
358 }
359 }
360 })
361 }
362
363 // FIXME: make independend font-size
364 // if (o.fontSize) {
365 // let unit = parseUnit(o.fontSize)
366 // let fs = Math.round(unit[0] * px(unit[1]))
367
368 // if (fs != this.fontSize) {
369 // newFontSize = true
370 // this.fontSize = fs
371 // }
372 // }
373
374 if (newFont || newFontSize) {
375 this.font.forEach((font, i) => {
376 let fontString = Font.stringify({
377 size: this.fontSize[i],
378 family: font.family,
379 stretch: isStretchSupported ? font.stretch : undefined,
380 variant: font.variant,
381 weight: font.weight,
382 style: font.style
383 })
384
385 // calc new font size atlas
386 this.fontAtlas[i] = this.shader.atlas[fontString]
387
388 if (!this.fontAtlas[i]) {
389 let metrics = font.metrics
390
391 this.shader.atlas[fontString] =
392 this.fontAtlas[i] = {
393 fontString,
394 // even step is better for rendered characters
395 step: Math.ceil(this.fontSize[i] * metrics.bottom * .5) * 2,
396 em: this.fontSize[i],
397 cols: 0,
398 rows: 0,
399 height: 0,
400 width: 0,
401 chars: [],
402 ids: {},
403 texture: this.regl.texture()
404 }
405 }
406
407 // bump atlas characters
408 if (o.text == null) o.text = this.text
409 })
410 }
411
412 // if multiple positions - duplicate text arguments
413 // FIXME: this possibly can be done better to avoid array spawn
414 if (typeof o.text === 'string' && o.position && o.position.length > 2) {
415 let textArray = Array(o.position.length * .5)
416 for (let i = 0; i < textArray.length; i++) {
417 textArray[i] = o.text
418 }
419 o.text = textArray
420 }
421
422 // calculate offsets for the new font/text
423 let newAtlasChars
424 if (o.text != null || newFont) {
425 // FIXME: ignore spaces
426 // text offsets within the text buffer
427 this.textOffsets = [0]
428
429 if (Array.isArray(o.text)) {
430 this.count = o.text[0].length
431 this.counts = [this.count]
432 for (let i = 1; i < o.text.length; i++) {
433 this.textOffsets[i] = this.textOffsets[i - 1] + o.text[i - 1].length
434 this.count += o.text[i].length
435 this.counts.push(o.text[i].length)
436 }
437 this.text = o.text.join('')
438 }
439 else {
440 this.text = o.text
441 this.count = this.text.length
442 this.counts = [this.count]
443 }
444
445 newAtlasChars = []
446
447 // detect & measure new characters
448 this.font.forEach((font, idx) => {
449 GlText.atlasContext.font = font.baseString
450
451 let atlas = this.fontAtlas[idx]
452
453 for (let i = 0; i < this.text.length; i++) {
454 let char = this.text.charAt(i)
455
456 if (atlas.ids[char] == null) {
457 atlas.ids[char] = atlas.chars.length
458 atlas.chars.push(char)
459 newAtlasChars.push(char)
460 }
461
462 if (font.width[char] == null) {
463 font.width[char] = GlText.atlasContext.measureText(char).width / GlText.baseFontSize
464
465 // measure kerning pairs for the new character
466 if (this.kerning) {
467 let pairs = []
468 for (let baseChar in font.width) {
469 pairs.push(baseChar + char, char + baseChar)
470 }
471 extend(font.kerning, kerning(font.family, {
472 pairs
473 }))
474 }
475 }
476 }
477 })
478 }
479
480 // create single position buffer (faster than batch or multiple separate instances)
481 if (o.position) {
482 if (o.position.length > 2) {
483 let flat = !o.position[0].length
484 let positionData = pool.mallocFloat(this.count * 2)
485 for (let i = 0, ptr = 0; i < this.counts.length; i++) {
486 let count = this.counts[i]
487 if (flat) {
488 for (let j = 0; j < count; j++) {
489 positionData[ptr++] = o.position[i * 2]
490 positionData[ptr++] = o.position[i * 2 + 1]
491 }
492 }
493 else {
494 for (let j = 0; j < count; j++) {
495 positionData[ptr++] = o.position[i][0]
496 positionData[ptr++] = o.position[i][1]
497 }
498 }
499 }
500 if (this.position.call) {
501 this.position({
502 type: 'float',
503 data: positionData
504 })
505 } else {
506 this.position = this.regl.buffer({
507 type: 'float',
508 data: positionData
509 })
510 }
511 pool.freeFloat(positionData)
512 }
513 else {
514 if (this.position.destroy) this.position.destroy()
515 this.position = {
516 constant: o.position
517 }
518 }
519 }
520
521 // populate text/offset buffers if font/text has changed
522 // as [charWidth, offset, charWidth, offset...]
523 // that is in em units since font-size can change often
524 if (o.text || newFont) {
525 let charIds = pool.mallocUint8(this.count)
526 let sizeData = pool.mallocFloat(this.count * 2)
527 this.textWidth = []
528
529 for (let i = 0, ptr = 0; i < this.counts.length; i++) {
530 let count = this.counts[i]
531 let font = this.font[i] || this.font[0]
532 let atlas = this.fontAtlas[i] || this.fontAtlas[0]
533
534 for (let j = 0; j < count; j++) {
535 let char = this.text.charAt(ptr)
536 let prevChar = this.text.charAt(ptr - 1)
537
538 charIds[ptr] = atlas.ids[char]
539 sizeData[ptr * 2] = font.width[char]
540
541 if (j) {
542 let prevWidth = sizeData[ptr * 2 - 2]
543 let currWidth = sizeData[ptr * 2]
544 let prevOffset = sizeData[ptr * 2 - 1]
545 let offset = prevOffset + prevWidth * .5 + currWidth * .5;
546
547 if (this.kerning) {
548 let kerning = font.kerning[prevChar + char]
549 if (kerning) {
550 offset += kerning * 1e-3
551 }
552 }
553
554 sizeData[ptr * 2 + 1] = offset
555 }
556 else {
557 sizeData[ptr * 2 + 1] = sizeData[ptr * 2] * .5
558 }
559
560 ptr++
561 }
562 this.textWidth.push(
563 !sizeData.length ? 0 :
564 // last offset + half last width
565 sizeData[ptr * 2 - 2] * .5 + sizeData[ptr * 2 - 1]
566 )
567 }
568
569
570 // bump recalc align offset
571 if (!o.align) o.align = this.align
572 this.charBuffer({data: charIds, type: 'uint8', usage: 'stream'})
573 this.sizeBuffer({data: sizeData, type: 'float', usage: 'stream'})
574 pool.freeUint8(charIds)
575 pool.freeFloat(sizeData)
576
577 // udpate font atlas and texture
578 if (newAtlasChars.length) {
579 this.font.forEach((font, i) => {
580 let atlas = this.fontAtlas[i]
581
582 // FIXME: insert metrics-based ratio here
583 let step = atlas.step
584
585 let maxCols = Math.floor(GlText.maxAtlasSize / step)
586 let cols = Math.min(maxCols, atlas.chars.length)
587 let rows = Math.ceil(atlas.chars.length / cols)
588
589 let atlasWidth = nextPow2( cols * step )
590 // let atlasHeight = Math.min(rows * step + step * .5, GlText.maxAtlasSize);
591 let atlasHeight = nextPow2( rows * step );
592
593 atlas.width = atlasWidth
594 atlas.height = atlasHeight;
595 atlas.rows = rows
596 atlas.cols = cols
597
598 if (!atlas.em) return
599
600 atlas.texture({
601 data: fontAtlas({
602 canvas: GlText.atlasCanvas,
603 font: atlas.fontString,
604 chars: atlas.chars,
605 shape: [atlasWidth, atlasHeight],
606 step: [step, step]
607 })
608 })
609
610 })
611 }
612 }
613
614 if (o.align) {
615 this.align = o.align
616 this.alignOffset = this.textWidth.map((textWidth, i) => {
617 let align = !Array.isArray(this.align) ? this.align : this.align.length > 1 ? this.align[i] : this.align[0]
618
619 if (typeof align === 'number') return align
620 switch (align) {
621 case 'right':
622 case 'end':
623 return -textWidth
624 case 'center':
625 case 'centre':
626 case 'middle':
627 return -textWidth * .5
628 }
629
630 return 0
631 })
632 }
633
634 if (this.baseline == null && o.baseline == null) {
635 o.baseline = 0
636 }
637 if (o.baseline != null) {
638 this.baseline = o.baseline
639 if (!Array.isArray(this.baseline)) this.baseline = [this.baseline]
640 this.baselineOffset = this.baseline.map((baseline, i) => {
641 let m = (this.font[i] || this.font[0]).metrics
642 let base = 0
643
644 base += m.bottom * .5
645
646 if (typeof baseline === 'number') {
647 base += (baseline - m.baseline)
648 }
649 else {
650 base += -m[baseline]
651 }
652
653 base *= -1
654 return base
655 })
656 }
657
658 // flatten colors to a single uint8 array
659 if (o.color != null) {
660 if (!o.color) o.color = 'transparent'
661
662 // single color
663 if (typeof o.color === 'string' || !isNaN(o.color)) {
664 this.color = rgba(o.color, 'uint8')
665 }
666 // array
667 else {
668 let colorData
669
670 // flat array
671 if (typeof o.color[0] === 'number' && o.color.length > this.counts.length) {
672 let l = o.color.length
673 colorData = pool.mallocUint8(l)
674 let sub = (o.color.subarray || o.color.slice).bind(o.color)
675 for (let i = 0; i < l; i += 4) {
676 colorData.set(rgba(sub(i, i + 4), 'uint8'), i)
677 }
678 }
679 // nested array
680 else {
681 let l = o.color.length
682 colorData = pool.mallocUint8(l * 4)
683 for (let i = 0; i < l; i++) {
684 colorData.set(rgba(o.color[i] || 0, 'uint8'), i * 4)
685 }
686 }
687
688 this.color = colorData
689 }
690 }
691
692 // update render batch
693 if (o.position || o.text || o.color || o.baseline || o.align || o.font || o.offset || o.opacity) {
694 let isBatch = (this.color.length > 4)
695 || (this.baselineOffset.length > 1)
696 || (this.align && this.align.length > 1)
697 || (this.fontAtlas.length > 1)
698 || (this.positionOffset.length > 2)
699 if (isBatch) {
700 let length = Math.max(
701 this.position.length * .5 || 0,
702 this.color.length * .25 || 0,
703 this.baselineOffset.length || 0,
704 this.alignOffset.length || 0,
705 this.font.length || 0,
706 this.opacity.length || 0,
707 this.positionOffset.length * .5 || 0
708 )
709 this.batch = Array(length)
710 for (let i = 0; i < this.batch.length; i++) {
711 this.batch[i] = {
712 count: this.counts.length > 1 ? this.counts[i] : this.counts[0],
713 offset: this.textOffsets.length > 1 ? this.textOffsets[i] : this.textOffsets[0],
714 color: !this.color ? [0,0,0,255] : this.color.length <= 4 ? this.color : this.color.subarray(i * 4, i * 4 + 4),
715 opacity: Array.isArray(this.opacity) ? this.opacity[i] : this.opacity,
716 baseline: this.baselineOffset[i] != null ? this.baselineOffset[i] : this.baselineOffset[0],
717 align: !this.align ? 0 : this.alignOffset[i] != null ? this.alignOffset[i] : this.alignOffset[0],
718 atlas: this.fontAtlas[i] || this.fontAtlas[0],
719 positionOffset: this.positionOffset.length > 2 ? this.positionOffset.subarray(i * 2, i * 2 + 2) : this.positionOffset
720 }
721 }
722 }
723 // single-color, single-baseline, single-align batch is faster to render
724 else {
725 if (this.count) {
726 this.batch = [{
727 count: this.count,
728 offset: 0,
729 color: this.color || [0,0,0,255],
730 opacity: Array.isArray(this.opacity) ? this.opacity[0] : this.opacity,
731 baseline: this.baselineOffset[0],
732 align: this.alignOffset ? this.alignOffset[0] : 0,
733 atlas: this.fontAtlas[0],
734 positionOffset: this.positionOffset
735 }]
736 }
737 else {
738 this.batch = []
739 }
740 }
741 }
742 }
743
744 destroy () {
745 // TODO: count instances of atlases and destroy all on null
746 }
747}
748
749
750// defaults
751GlText.prototype.kerning = true
752GlText.prototype.position = { constant: new Float32Array(2) }
753GlText.prototype.translate = null
754GlText.prototype.scale = null
755GlText.prototype.font = null
756GlText.prototype.text = ''
757GlText.prototype.positionOffset = [0, 0]
758GlText.prototype.opacity = 1
759GlText.prototype.color = new Uint8Array([0, 0, 0, 255])
760GlText.prototype.alignOffset = [0, 0]
761
762
763// size of an atlas
764GlText.maxAtlasSize = 1024
765
766// font atlas canvas is singleton
767GlText.atlasCanvas = document.createElement('canvas')
768GlText.atlasContext = GlText.atlasCanvas.getContext('2d', {alpha: false})
769
770// font-size used for metrics, atlas step calculation
771GlText.baseFontSize = 64
772
773// fonts storage
774GlText.fonts = {}
775
776// max number of different font atlases/textures cached
777// FIXME: enable atlas size limitation via LRU
778// GlText.atlasCacheSize = 64
779
780function isRegl (o) {
781 return typeof o === 'function' &&
782 o._gl &&
783 o.prop &&
784 o.texture &&
785 o.buffer
786}
787
788
789module.exports = GlText