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 vec2 uv = floor(gl_FragCoord.xy - charCoord + charStep * .5);
82 float halfCharStep = floor(charStep * .5 + .5);
83
84 // invert y and shift by 1px (FF expecially needs that)
85 uv.y = charStep - uv.y - .5;
86
87 // ignore points outside of character bounding box
88 float halfCharWidth = ceil(charWidth * .5);
89 if (floor(uv.x) > halfCharStep + halfCharWidth ||
90 floor(uv.x) < halfCharStep - halfCharWidth) return;
91
92 uv += charId * charStep;
93 uv = uv / atlasSize;
94
95 vec4 fontColor = color;
96 vec4 mask = texture2D(atlas, uv);
97
98 float maskY = lightness(mask);
99 // float colorY = lightness(fontColor);
100 fontColor.a *= maskY;
101
102 // fontColor.a += .1;
103
104 // antialiasing, see yiq color space y-channel formula
105 // fontColor.rgb += (1. - fontColor.rgb) * (1. - mask.rgb);
106
107 gl_FragColor = fontColor;
108 }`,
109
110 blend: {
111 enable: true,
112 color: [0,0,0,1],
113
114 func: {
115 srcRGB: 'src alpha',
116 dstRGB: 'one minus src alpha',
117 srcAlpha: 'one minus dst alpha',
118 dstAlpha: 'one'
119 }
120 },
121 stencil: {enable: false},
122 depth: {enable: false},
123
124 attributes: {
125 char: regl.this('charBuffer'),
126 charOffset: {
127 offset: 4,
128 stride: 8,
129 buffer: regl.this('sizeBuffer')
130 },
131 width: {
132 offset: 0,
133 stride: 8,
134 buffer: regl.this('sizeBuffer')
135 }
136 },
137 uniforms: {
138 position: regl.this('position'),
139 atlasSize: function () {
140 return [this.fontAtlas.width, this.fontAtlas.height]
141 },
142 atlasDim: function () {
143 return [this.fontAtlas.cols, this.fontAtlas.rows]
144 },
145 fontSize: regl.this('fontSize'),
146 em: function () { return this.fontSize },
147 atlas: function () { return this.fontAtlas.texture },
148 viewport: regl.this('viewportArray'),
149 color: regl.this('color'),
150 scale: regl.this('scale'),
151 align: regl.this('alignOffset'),
152 baseline: regl.this('baselineOffset'),
153 translate: regl.this('translate'),
154 charStep: function () {
155 return this.fontAtlas.step
156 },
157 offset: regl.this('offset')
158 },
159 primitive: 'points',
160 count: regl.this('count'),
161 viewport: regl.this('viewport')
162 })
163
164 // per font-size atlas
165 let atlas = {}
166
167 shader = { regl, draw, atlas }
168
169 shaderCache.set(this.gl, shader)
170 }
171
172 this.render = shader.draw.bind(this)
173 this.regl = shader.regl
174 this.canvas = this.gl.canvas
175 this.shader = shader
176
177 this.charBuffer = this.regl.buffer({ type: 'uint8', usage: 'stream' })
178 this.sizeBuffer = this.regl.buffer({ type: 'float', usage: 'stream' })
179
180 this.update(isObj(o) ? o : {})
181 }
182
183 update (o) {
184 if (typeof o === 'string') o = { text: o }
185 else if (!o) return
186
187 // FIXME: make this a static transform or more general approact
188 o = pick(o, {
189 font: 'font fontFace fontface typeface cssFont css-font family fontFamily',
190 fontSize: 'fontSize fontsize size font-size',
191 text: 'text value symbols',
192 align: 'align alignment textAlign textbaseline',
193 baseline: 'baseline textBaseline textbaseline',
194 direction: 'dir direction textDirection',
195 color: 'color colour fill fill-color fillColor textColor textcolor',
196 kerning: 'kerning kern',
197 range: 'range dataBox',
198 viewport: 'vp viewport viewBox viewbox viewPort',
199 opacity: 'opacity alpha transparency visible visibility opaque',
200 offset: 'offset padding shift indent indentation'
201 }, true)
202
203
204 if (o.opacity != null) this.opacity = parseFloat(o.opacity)
205
206 // FIXME: mb add multiple colors?
207 if (o.color) {
208 this.color = rgba(o.color)
209 }
210
211 if (o.viewport != null) {
212 this.viewport = parseRect(o.viewport)
213
214 if (GlText.normalViewport) {
215 this.viewport.y = this.canvas.height - this.viewport.y - this.viewport.height
216 }
217
218 this.viewportArray = [this.viewport.x, this.viewport.y, this.viewport.width, this.viewport.height]
219
220 }
221 if (this.viewport == null) {
222 this.viewport = {
223 x: 0, y: 0,
224 width: this.gl.drawingBufferWidth,
225 height: this.gl.drawingBufferHeight
226 }
227 this.viewportArray = [this.viewport.x, this.viewport.y, this.viewport.width, this.viewport.height]
228 }
229
230 if (o.kerning != null) this.kerning = o.kerning
231
232 if (o.offset != null) {
233 if (typeof o.offset === 'number') this.offset = [o.offset, 0]
234 else this.offset = o.offset.slice()
235
236 if (!GlText.normalViewport) {
237 this.offset[1] *= -1
238 }
239 }
240
241 if (o.direction) this.direction = o.direction
242
243 if (o.position) this.position = o.position
244
245 if (o.range) {
246 this.range = o.range
247 this.scale = [1 / (o.range[2] - o.range[0]), 1 / (o.range[3] - o.range[1])]
248 this.translate = [-o.range[0], -o.range[1]]
249 }
250 if (o.scale) this.scale = o.scale
251 if (o.translate) this.translate = o.translate
252
253 // default scale corresponds to viewport
254 if (!this.scale) this.scale = [1 / this.viewport.width, 1 / this.viewport.height]
255
256 if (!this.translate) this.translate = [0, 0]
257
258 if (!this.font && !o.font) o.font = GlText.baseFontSize + 'px sans-serif'
259
260 // normalize font caching string
261 let newFont = false, newFontSize = false
262
263 // normalize font
264 if (typeof o.font === 'string') {
265 try {
266 o.font = Font.parse(o.font)
267 } catch (e) {
268 o.font = Font.parse(GlText.baseFontSize + 'px ' + o.font)
269 }
270 }
271 else if (o.font) o.font = Font.parse(Font.stringify(o.font))
272
273 // obtain new font data
274 if (o.font) {
275 let baseString = Font.stringify({
276 size: GlText.baseFontSize,
277 family: o.font.family,
278 stretch: o.font.stretch,
279 variant: o.font.variant,
280 weight: o.font.weight,
281 style: o.font.style
282 })
283
284 let unit = parseUnit(o.font.size)
285 let fs = Math.round(unit[0] * px(unit[1]))
286 if (fs !== this.fontSize) {
287 newFontSize = true
288 this.fontSize = fs
289 }
290
291 // calc new font metrics/atlas
292 if (!this.font || baseString != this.font.baseString) {
293 newFont = true
294
295 // obtain font cache or create one
296 this.font = GlText.fonts[baseString]
297 if (!this.font) {
298 let family = o.font.family.join(', ')
299 this.font = {
300 baseString,
301
302 // typeface
303 family,
304 weight: o.font.weight,
305 stretch: o.font.stretch,
306 style: o.font.style,
307 variant: o.font.variant,
308
309 // widths of characters
310 width: {},
311
312 // kernin pairs offsets
313 kerning: {},
314
315 metrics: metrics(family, {
316 origin: 'top',
317 fontSize: GlText.baseFontSize,
318 fontStyle: `${o.font.style} ${o.font.variant} ${o.font.weight} ${o.font.stretch}`
319 })
320 }
321
322 GlText.fonts[baseString] = this.font
323 }
324 }
325 }
326
327 if (o.fontSize) {
328 let unit = parseUnit(o.fontSize)
329 let fs = Math.round(unit[0] * px(unit[1]))
330
331 if (fs != this.fontSize) {
332 newFontSize = true
333 this.fontSize = fs
334 }
335 }
336
337 if (newFont || newFontSize) {
338 this.fontString = Font.stringify({
339 size: this.fontSize,
340 family: this.font.family,
341 stretch: this.font.stretch,
342 variant: this.font.variant,
343 weight: this.font.weight,
344 style: this.font.style
345 })
346
347 // calc new font size atlas
348 this.fontAtlas = this.shader.atlas[this.fontString]
349
350 if (!this.fontAtlas) {
351 let metrics = this.font.metrics
352
353 this.shader.atlas[this.fontString] =
354 this.fontAtlas = {
355 // even step is better for rendered characters
356 step: Math.ceil(this.fontSize * metrics.bottom * .5) * 2,
357 em: this.fontSize,
358 cols: 0,
359 rows: 0,
360 height: 0,
361 width: 0,
362 chars: [],
363 ids: {},
364 texture: this.regl.texture()
365 }
366 }
367
368 // bump atlas characters
369 if (o.text == null) o.text = this.text
370 }
371
372 // calculate offsets for the new font/text
373 if (o.text != null || newFont) {
374 // FIXME: ignore spaces
375 this.text = o.text
376 this.count = o.text.length
377
378 let newAtlasChars = []
379
380 // detect & measure new characters
381 GlText.atlasContext.font = this.font.baseString
382
383 for (let i = 0; i < this.text.length; i++) {
384 let char = this.text.charAt(i)
385
386 if (this.fontAtlas.ids[char] == null) {
387 this.fontAtlas.ids[char] = this.fontAtlas.chars.length
388 this.fontAtlas.chars.push(char)
389 newAtlasChars.push(char)
390 }
391
392 if (this.font.width[char] == null) {
393 this.font.width[char] = GlText.atlasContext.measureText(char).width / GlText.baseFontSize
394
395 // measure kerning pairs for the new character
396 if (this.kerning) {
397 let pairs = []
398 for (let baseChar in this.font.width) {
399 pairs.push(baseChar + char, char + baseChar)
400 }
401 extend(this.font.kerning, kerning(this.font.family, {
402 pairs
403 }))
404 }
405 }
406 }
407
408 // populate text/offset buffers
409 // as [charWidth, offset, charWidth, offset...]
410 // that is in em units since font-size can change often
411 let charIds = pool.mallocUint8(this.count)
412 let sizeData = pool.mallocFloat(this.count * 2)
413 for (let i = 0; i < this.count; i++) {
414 let char = this.text.charAt(i)
415 let prevChar = this.text.charAt(i - 1)
416
417 charIds[i] = this.fontAtlas.ids[char]
418 sizeData[i * 2] = this.font.width[char]
419
420 if (i) {
421 let prevWidth = sizeData[i * 2 - 2]
422 let currWidth = sizeData[i * 2]
423 let prevOffset = sizeData[i * 2 - 1]
424 let offset = prevOffset + prevWidth * .5 + currWidth * .5;
425
426 if (this.kerning) {
427 let kerning = this.font.kerning[prevChar + char]
428 if (kerning) {
429 offset += kerning * 1e-3
430 }
431 }
432
433 sizeData[i * 2 + 1] = offset
434 }
435 else {
436 sizeData[1] = sizeData[0] * .5
437 }
438 }
439
440 if (this.count) {
441 this.textWidth = (sizeData[sizeData.length - 2] * .5 + sizeData[sizeData.length - 1])
442 } else {
443 this.textWidth = 0
444 }
445 this.alignOffset = alignOffset(this.align, this.textWidth)
446
447 this.charBuffer({data: charIds, type: 'uint8', usage: 'stream'})
448 this.sizeBuffer({data: sizeData, type: 'float', usage: 'stream'})
449 pool.freeUint8(charIds)
450 pool.freeFloat(sizeData)
451
452 // udpate font atlas and texture
453 if (newAtlasChars.length) {
454 // FIXME: insert metrics-based ratio here
455 let step = this.fontAtlas.step
456
457 let maxCols = Math.floor(GlText.maxAtlasSize / step)
458 let cols = Math.min(maxCols, this.fontAtlas.chars.length)
459 let rows = Math.ceil(this.fontAtlas.chars.length / cols)
460
461 let atlasWidth = cols * step
462 // let atlasHeight = Math.min(rows * step + step * .5, GlText.maxAtlasSize);
463 let atlasHeight = rows * step;
464
465 this.fontAtlas.width = atlasWidth
466 this.fontAtlas.height = atlasHeight;
467 this.fontAtlas.rows = rows
468 this.fontAtlas.cols = cols
469 this.fontAtlas.texture({
470 data: fontAtlas({
471 canvas: GlText.atlasCanvas,
472 font: this.fontString,
473 chars: this.fontAtlas.chars,
474 shape: [atlasWidth, atlasHeight],
475 step: [step, step]
476 })
477 })
478 }
479 }
480
481 if (o.align) {
482 this.align = o.align
483 this.alignOffset = alignOffset(this.align, this.textWidth)
484 }
485
486 if (this.baseline == null && o.baseline == null) {
487 o.baseline = 0
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