UNPKG

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