'use strict' var Font = require('css-font') var pick = require('pick-by-alias') var createRegl = require('regl') var createGl = require('gl-util/context') var WeakMap = require('es6-weak-map') var rgba = require('color-normalize') var fontAtlas = require('font-atlas') var pool = require('typedarray-pool') var parseRect = require('parse-rect') var isObj = require('is-plain-obj') var parseUnit = require('parse-unit') var px = require('to-px') var kerning = require('detect-kerning') var extend = require('object-assign') var metrics = require('font-measure') var flatten = require('flatten-vertex-data') var ref = require('bit-twiddle'); var nextPow2 = ref.nextPow2; var shaderCache = new WeakMap // Safari does not support font-stretch var isStretchSupported = false if (document.body) { var el = document.body.appendChild(document.createElement('div')) el.style.font = 'italic small-caps bold condensed 16px/2 cursive' if (getComputedStyle(el).fontStretch) { isStretchSupported = true } document.body.removeChild(el) } var GlText = function GlText (o) { if (isRegl(o)) { o = {regl: o} this.gl = o.regl._gl } else { this.gl = createGl(o) } this.shader = shaderCache.get(this.gl) if (!this.shader) { this.regl = o.regl || createRegl({ gl: this.gl }) } else { this.regl = this.shader.regl } this.charBuffer = this.regl.buffer({ type: 'uint8', usage: 'stream' }) this.sizeBuffer = this.regl.buffer({ type: 'float', usage: 'stream' }) if (!this.shader) { this.shader = this.createShader() shaderCache.set(this.gl, this.shader) } this.batch = [] // multiple options initial state this.fontSize = [] this.font = [] this.fontAtlas = [] this.draw = this.shader.draw.bind(this) this.render = function () { // FIXME: add Safari regl report here: // charBuffer and width just do not trigger this.regl._refresh() this.draw(this.batch) } this.canvas = this.gl.canvas this.update(isObj(o) ? o : {}) }; GlText.prototype.createShader = function createShader () { var regl = this.regl var draw = regl({ blend: { enable: true, color: [0,0,0,1], func: { srcRGB: 'src alpha', dstRGB: 'one minus src alpha', srcAlpha: 'one minus dst alpha', dstAlpha: 'one' } }, stencil: {enable: false}, depth: {enable: false}, count: regl.prop('count'), offset: regl.prop('offset'), attributes: { charOffset: { offset: 4, stride: 8, buffer: regl.this('sizeBuffer') }, width: { offset: 0, stride: 8, buffer: regl.this('sizeBuffer') }, char: regl.this('charBuffer'), position: regl.this('position') }, uniforms: { atlasSize: function (c, p) { return [p.atlas.width, p.atlas.height]; }, atlasDim: function (c, p) { return [p.atlas.cols, p.atlas.rows]; }, atlas: function (c, p) { return p.atlas.texture; }, charStep: function (c, p) { return p.atlas.step; }, em: function (c, p) { return p.atlas.em; }, color: regl.prop('color'), opacity: regl.prop('opacity'), viewport: regl.this('viewportArray'), scale: regl.this('scale'), align: regl.prop('align'), baseline: regl.prop('baseline'), translate: regl.this('translate'), positionOffset: regl.prop('positionOffset') }, primitive: 'points', viewport: regl.this('viewport'), vert: "\n\t\t\tprecision highp float;\n\t\t\tattribute float width, charOffset, char;\n\t\t\tattribute vec2 position;\n\t\t\tuniform float fontSize, charStep, em, align, baseline;\n\t\t\tuniform vec4 viewport;\n\t\t\tuniform vec4 color;\n\t\t\tuniform vec2 atlasSize, atlasDim, scale, translate, positionOffset;\n\t\t\tvarying vec2 charCoord, charId;\n\t\t\tvarying float charWidth;\n\t\t\tvarying vec4 fontColor;\n\t\t\tvoid main () {\n\t\t\t\tvec2 offset = floor(em * (vec2(align + charOffset, baseline)\n\t\t\t\t\t+ vec2(positionOffset.x, -positionOffset.y)))\n\t\t\t\t\t/ (viewport.zw * scale.xy);\n\n\t\t\t\tvec2 position = (position + translate) * scale;\n\t\t\t\tposition += offset * scale;\n\n\t\t\t\tcharCoord = position * viewport.zw + viewport.xy;\n\n\t\t\t\tgl_Position = vec4(position * 2. - 1., 0, 1);\n\n\t\t\t\tgl_PointSize = charStep;\n\n\t\t\t\tcharId.x = mod(char, atlasDim.x);\n\t\t\t\tcharId.y = floor(char / atlasDim.x);\n\n\t\t\t\tcharWidth = width * em;\n\n\t\t\t\tfontColor = color / 255.;\n\t\t\t}", frag: "\n\t\t\tprecision highp float;\n\t\t\tuniform float fontSize, charStep, opacity;\n\t\t\tuniform vec2 atlasSize;\n\t\t\tuniform vec4 viewport;\n\t\t\tuniform sampler2D atlas;\n\t\t\tvarying vec4 fontColor;\n\t\t\tvarying vec2 charCoord, charId;\n\t\t\tvarying float charWidth;\n\n\t\t\tfloat lightness(vec4 color) {\n\t\t\t\treturn color.r * 0.299 + color.g * 0.587 + color.b * 0.114;\n\t\t\t}\n\n\t\t\tvoid main () {\n\t\t\t\tvec2 uv = gl_FragCoord.xy - charCoord + charStep * .5;\n\t\t\t\tfloat halfCharStep = floor(charStep * .5 + .5);\n\n\t\t\t\t// invert y and shift by 1px (FF expecially needs that)\n\t\t\t\tuv.y = charStep - uv.y;\n\n\t\t\t\t// ignore points outside of character bounding box\n\t\t\t\tfloat halfCharWidth = ceil(charWidth * .5);\n\t\t\t\tif (floor(uv.x) > halfCharStep + halfCharWidth ||\n\t\t\t\t\tfloor(uv.x) < halfCharStep - halfCharWidth) return;\n\n\t\t\t\tuv += charId * charStep;\n\t\t\t\tuv = uv / atlasSize;\n\n\t\t\t\tvec4 color = fontColor;\n\t\t\t\tvec4 mask = texture2D(atlas, uv);\n\n\t\t\t\tfloat maskY = lightness(mask);\n\t\t\t\t// float colorY = lightness(color);\n\t\t\t\tcolor.a *= maskY;\n\t\t\t\tcolor.a *= opacity;\n\n\t\t\t\t// color.a += .1;\n\n\t\t\t\t// antialiasing, see yiq color space y-channel formula\n\t\t\t\t// color.rgb += (1. - color.rgb) * (1. - mask.rgb);\n\n\t\t\t\tgl_FragColor = color;\n\t\t\t}" }) // per font-size atlas var atlas = {} return { regl: regl, draw: draw, atlas: atlas } }; GlText.prototype.update = function update (o) { var this$1 = this; if (typeof o === 'string') { o = { text: o } } else if (!o) { return } // FIXME: make this a static transform or more general approact o = pick(o, { position: 'position positions coord coords coordinates', font: 'font fontFace fontface typeface cssFont css-font family fontFamily', fontSize: 'fontSize fontsize size font-size', text: 'text texts chars characters value values symbols', align: 'align alignment textAlign textbaseline', baseline: 'baseline textBaseline textbaseline', direction: 'dir direction textDirection', color: 'color colour fill fill-color fillColor textColor textcolor', kerning: 'kerning kern', range: 'range dataBox', viewport: 'vp viewport viewBox viewbox viewPort', opacity: 'opacity alpha transparency visible visibility opaque', offset: 'offset positionOffset padding shift indent indentation' }, true) if (o.opacity != null) { if (Array.isArray(o.opacity)) { this.opacity = o.opacity.map(function (o) { return parseFloat(o); }) } else { this.opacity = parseFloat(o.opacity) } } if (o.viewport != null) { this.viewport = parseRect(o.viewport) this.viewportArray = [this.viewport.x, this.viewport.y, this.viewport.width, this.viewport.height] } if (this.viewport == null) { this.viewport = { x: 0, y: 0, width: this.gl.drawingBufferWidth, height: this.gl.drawingBufferHeight } this.viewportArray = [this.viewport.x, this.viewport.y, this.viewport.width, this.viewport.height] } if (o.kerning != null) { this.kerning = o.kerning } if (o.offset != null) { if (typeof o.offset === 'number') { o.offset = [o.offset, 0] } this.positionOffset = flatten(o.offset) } if (o.direction) { this.direction = o.direction } if (o.range) { this.range = o.range this.scale = [1 / (o.range[2] - o.range[0]), 1 / (o.range[3] - o.range[1])] this.translate = [-o.range[0], -o.range[1]] } if (o.scale) { this.scale = o.scale } if (o.translate) { this.translate = o.translate } // default scale corresponds to viewport if (!this.scale) { this.scale = [1 / this.viewport.width, 1 / this.viewport.height] } if (!this.translate) { this.translate = [0, 0] } if (!this.font.length && !o.font) { o.font = GlText.baseFontSize + 'px sans-serif' } // normalize font caching string var newFont = false, newFontSize = false // obtain new font data if (o.font) { (Array.isArray(o.font) ? o.font : [o.font]).forEach(function (font, i) { // normalize font if (typeof font === 'string') { try { font = Font.parse(font) } catch (e) { font = Font.parse(GlText.baseFontSize + 'px ' + font) } } else { var fontStyle = font.style var fontWeight = font.weight var fontStretch = font.stretch var fontVariant = font.variant font = Font.parse(Font.stringify(font)) if (fontStyle) font.style = fontStyle if (fontWeight) font.weight = fontWeight if (fontStretch) font.stretch = fontStretch if (fontVariant) font.variant = fontVariant } var baseString = Font.stringify({ size: GlText.baseFontSize, family: font.family, stretch: isStretchSupported ? font.stretch : undefined, variant: font.variant, weight: font.weight, style: font.style }) var unit = parseUnit(font.size) var fs = Math.round(unit[0] * px(unit[1])) if (fs !== this$1.fontSize[i]) { newFontSize = true this$1.fontSize[i] = fs } // calc new font metrics/atlas if (!this$1.font[i] || baseString != this$1.font[i].baseString) { newFont = true // obtain font cache or create one this$1.font[i] = GlText.fonts[baseString] if (!this$1.font[i]) { var family = font.family.join(', ') var style = [font.style] if (font.style != font.variant) { style.push(font.variant) } if (font.variant != font.weight) { style.push(font.weight) } if (isStretchSupported && font.weight != font.stretch) { style.push(font.stretch) } this$1.font[i] = { baseString: baseString, // typeface family: family, weight: font.weight, stretch: font.stretch, style: font.style, variant: font.variant, // widths of characters width: {}, // kernin pairs offsets kerning: {}, metrics: metrics(family, { origin: 'top', fontSize: GlText.baseFontSize, fontStyle: style.join(' ') }) } GlText.fonts[baseString] = this$1.font[i] } } }) } // FIXME: make independend font-size // if (o.fontSize) { // let unit = parseUnit(o.fontSize) // let fs = Math.round(unit[0] * px(unit[1])) // if (fs != this.fontSize) { // newFontSize = true // this.fontSize = fs // } // } if (newFont || newFontSize) { this.font.forEach(function (font, i) { var fontString = Font.stringify({ size: this$1.fontSize[i], family: font.family, stretch: isStretchSupported ? font.stretch : undefined, variant: font.variant, weight: font.weight, style: font.style }) // calc new font size atlas this$1.fontAtlas[i] = this$1.shader.atlas[fontString] if (!this$1.fontAtlas[i]) { var metrics = font.metrics this$1.shader.atlas[fontString] = this$1.fontAtlas[i] = { fontString: fontString, // even step is better for rendered characters step: Math.ceil(this$1.fontSize[i] * metrics.bottom * .5) * 2, em: this$1.fontSize[i], cols: 0, rows: 0, height: 0, width: 0, chars: [], ids: {}, texture: this$1.regl.texture() } } // bump atlas characters if (o.text == null) { o.text = this$1.text } }) } // if multiple positions - duplicate text arguments // FIXME: this possibly can be done better to avoid array spawn if (typeof o.text === 'string' && o.position && o.position.length > 2) { var textArray = Array(o.position.length * .5) for (var i = 0; i < textArray.length; i++) { textArray[i] = o.text } o.text = textArray } // calculate offsets for the new font/text var newAtlasChars if (o.text != null || newFont) { // FIXME: ignore spaces // text offsets within the text buffer this.textOffsets = [0] if (Array.isArray(o.text)) { this.count = o.text[0].length this.counts = [this.count] for (var i$1 = 1; i$1 < o.text.length; i$1++) { this.textOffsets[i$1] = this.textOffsets[i$1 - 1] + o.text[i$1 - 1].length this.count += o.text[i$1].length this.counts.push(o.text[i$1].length) } this.text = o.text.join('') } else { this.text = o.text this.count = this.text.length this.counts = [this.count] } newAtlasChars = [] // detect & measure new characters this.font.forEach(function (font, idx) { GlText.atlasContext.font = font.baseString var atlas = this$1.fontAtlas[idx] for (var i = 0; i < this$1.text.length; i++) { var char = this$1.text.charAt(i) if (atlas.ids[char] == null) { atlas.ids[char] = atlas.chars.length atlas.chars.push(char) newAtlasChars.push(char) } if (font.width[char] == null) { font.width[char] = GlText.atlasContext.measureText(char).width / GlText.baseFontSize // measure kerning pairs for the new character if (this$1.kerning) { var pairs = [] for (var baseChar in font.width) { pairs.push(baseChar + char, char + baseChar) } extend(font.kerning, kerning(font.family, { pairs: pairs })) } } } }) } // create single position buffer (faster than batch or multiple separate instances) if (o.position) { if (o.position.length > 2) { var flat = !o.position[0].length var positionData = pool.mallocFloat(this.count * 2) for (var i$2 = 0, ptr = 0; i$2 < this.counts.length; i$2++) { var count = this.counts[i$2] if (flat) { for (var j = 0; j < count; j++) { positionData[ptr++] = o.position[i$2 * 2] positionData[ptr++] = o.position[i$2 * 2 + 1] } } else { for (var j$1 = 0; j$1 < count; j$1++) { positionData[ptr++] = o.position[i$2][0] positionData[ptr++] = o.position[i$2][1] } } } if (this.position.call) { this.position({ type: 'float', data: positionData }) } else { this.position = this.regl.buffer({ type: 'float', data: positionData }) } pool.freeFloat(positionData) } else { if (this.position.destroy) { this.position.destroy() } this.position = { constant: o.position } } } // populate text/offset buffers if font/text has changed // as [charWidth, offset, charWidth, offset...] // that is in em units since font-size can change often if (o.text || newFont) { var charIds = pool.mallocUint8(this.count) var sizeData = pool.mallocFloat(this.count * 2) this.textWidth = [] for (var i$3 = 0, ptr$1 = 0; i$3 < this.counts.length; i$3++) { var count$1 = this.counts[i$3] var font = this.font[i$3] || this.font[0] var atlas = this.fontAtlas[i$3] || this.fontAtlas[0] for (var j$2 = 0; j$2 < count$1; j$2++) { var char = this.text.charAt(ptr$1) var prevChar = this.text.charAt(ptr$1 - 1) charIds[ptr$1] = atlas.ids[char] sizeData[ptr$1 * 2] = font.width[char] if (j$2) { var prevWidth = sizeData[ptr$1 * 2 - 2] var currWidth = sizeData[ptr$1 * 2] var prevOffset = sizeData[ptr$1 * 2 - 1] var offset = prevOffset + prevWidth * .5 + currWidth * .5; if (this.kerning) { var kerning$1 = font.kerning[prevChar + char] if (kerning$1) { offset += kerning$1 * 1e-3 } } sizeData[ptr$1 * 2 + 1] = offset } else { sizeData[ptr$1 * 2 + 1] = sizeData[ptr$1 * 2] * .5 } ptr$1++ } this.textWidth.push( !sizeData.length ? 0 : // last offset + half last width sizeData[ptr$1 * 2 - 2] * .5 + sizeData[ptr$1 * 2 - 1] ) } // bump recalc align offset if (!o.align) { o.align = this.align } this.charBuffer({data: charIds, type: 'uint8', usage: 'stream'}) this.sizeBuffer({data: sizeData, type: 'float', usage: 'stream'}) pool.freeUint8(charIds) pool.freeFloat(sizeData) // udpate font atlas and texture if (newAtlasChars.length) { this.font.forEach(function (font, i) { var atlas = this$1.fontAtlas[i] // FIXME: insert metrics-based ratio here var step = atlas.step var maxCols = Math.floor(GlText.maxAtlasSize / step) var cols = Math.min(maxCols, atlas.chars.length) var rows = Math.ceil(atlas.chars.length / cols) var atlasWidth = nextPow2( cols * step ) // let atlasHeight = Math.min(rows * step + step * .5, GlText.maxAtlasSize); var atlasHeight = nextPow2( rows * step ); atlas.width = atlasWidth atlas.height = atlasHeight; atlas.rows = rows atlas.cols = cols if (!atlas.em) { return } atlas.texture({ data: fontAtlas({ canvas: GlText.atlasCanvas, font: atlas.fontString, chars: atlas.chars, shape: [atlasWidth, atlasHeight], step: [step, step] }) }) }) } } if (o.align) { this.align = o.align this.alignOffset = this.textWidth.map(function (textWidth, i) { var align = !Array.isArray(this$1.align) ? this$1.align : this$1.align.length > 1 ? this$1.align[i] : this$1.align[0] if (typeof align === 'number') { return align } switch (align) { case 'right': case 'end': return -textWidth case 'center': case 'centre': case 'middle': return -textWidth * .5 } return 0 }) } if (this.baseline == null && o.baseline == null) { o.baseline = 0 } if (o.baseline != null) { this.baseline = o.baseline if (!Array.isArray(this.baseline)) { this.baseline = [this.baseline] } this.baselineOffset = this.baseline.map(function (baseline, i) { var m = (this$1.font[i] || this$1.font[0]).metrics var base = 0 base += m.bottom * .5 if (typeof baseline === 'number') { base += (baseline - m.baseline) } else { base += -m[baseline] } base *= -1 return base }) } // flatten colors to a single uint8 array if (o.color != null) { if (!o.color) { o.color = 'transparent' } // single color if (typeof o.color === 'string' || !isNaN(o.color)) { this.color = rgba(o.color, 'uint8') } // array else { var colorData // flat array if (typeof o.color[0] === 'number' && o.color.length > this.counts.length) { var l = o.color.length colorData = pool.mallocUint8(l) var sub = (o.color.subarray || o.color.slice).bind(o.color) for (var i$4 = 0; i$4 < l; i$4 += 4) { colorData.set(rgba(sub(i$4, i$4 + 4), 'uint8'), i$4) } } // nested array else { var l$1 = o.color.length colorData = pool.mallocUint8(l$1 * 4) for (var i$5 = 0; i$5 < l$1; i$5++) { colorData.set(rgba(o.color[i$5] || 0, 'uint8'), i$5 * 4) } } this.color = colorData } } // update render batch if (o.position || o.text || o.color || o.baseline || o.align || o.font || o.offset || o.opacity) { var isBatch = (this.color.length > 4) || (this.baselineOffset.length > 1) || (this.align && this.align.length > 1) || (this.fontAtlas.length > 1) || (this.positionOffset.length > 2) if (isBatch) { var length = Math.max( this.position.length * .5 || 0, this.color.length * .25 || 0, this.baselineOffset.length || 0, this.alignOffset.length || 0, this.font.length || 0, this.opacity.length || 0, this.positionOffset.length * .5 || 0 ) this.batch = Array(length) for (var i$6 = 0; i$6 < this.batch.length; i$6++) { this.batch[i$6] = { count: this.counts.length > 1 ? this.counts[i$6] : this.counts[0], offset: this.textOffsets.length > 1 ? this.textOffsets[i$6] : this.textOffsets[0], color: !this.color ? [0,0,0,255] : this.color.length <= 4 ? this.color : this.color.subarray(i$6 * 4, i$6 * 4 + 4), opacity: Array.isArray(this.opacity) ? this.opacity[i$6] : this.opacity, baseline: this.baselineOffset[i$6] != null ? this.baselineOffset[i$6] : this.baselineOffset[0], align: !this.align ? 0 : this.alignOffset[i$6] != null ? this.alignOffset[i$6] : this.alignOffset[0], atlas: this.fontAtlas[i$6] || this.fontAtlas[0], positionOffset: this.positionOffset.length > 2 ? this.positionOffset.subarray(i$6 * 2, i$6 * 2 + 2) : this.positionOffset } } } // single-color, single-baseline, single-align batch is faster to render else { if (this.count) { this.batch = [{ count: this.count, offset: 0, color: this.color || [0,0,0,255], opacity: Array.isArray(this.opacity) ? this.opacity[0] : this.opacity, baseline: this.baselineOffset[0], align: this.alignOffset ? this.alignOffset[0] : 0, atlas: this.fontAtlas[0], positionOffset: this.positionOffset }] } else { this.batch = [] } } } }; GlText.prototype.destroy = function destroy () { // TODO: count instances of atlases and destroy all on null }; // defaults GlText.prototype.kerning = true GlText.prototype.position = { constant: new Float32Array(2) } GlText.prototype.translate = null GlText.prototype.scale = null GlText.prototype.font = null GlText.prototype.text = '' GlText.prototype.positionOffset = [0, 0] GlText.prototype.opacity = 1 GlText.prototype.color = new Uint8Array([0, 0, 0, 255]) GlText.prototype.alignOffset = [0, 0] // size of an atlas GlText.maxAtlasSize = 1024 // font atlas canvas is singleton GlText.atlasCanvas = document.createElement('canvas') GlText.atlasContext = GlText.atlasCanvas.getContext('2d', {alpha: false}) // font-size used for metrics, atlas step calculation GlText.baseFontSize = 64 // fonts storage GlText.fonts = {} // max number of different font atlases/textures cached // FIXME: enable atlas size limitation via LRU // GlText.atlasCacheSize = 64 function isRegl (o) { return typeof o === 'function' && o._gl && o.prop && o.texture && o.buffer } module.exports = GlText