UNPKG

tangram

Version:
735 lines (618 loc) 31.2 kB
import log from '../../utils/log'; import Utils from '../../utils/utils'; import Texture from '../../gl/texture'; import FontManager from './font_manager'; import Task from '../../utils/task'; import StyleParser from '../style_parser'; import MultiLine from './text_wrap'; import { splitLabelText, isTextRTL, isTextNeutral, isTextCurveBlacklisted } from './text_segments'; import debugSettings from '../../utils/debug_settings'; export default class TextCanvas { constructor () { this.createCanvas(); // create initial canvas and context this.vertical_text_buffer = 8; // vertical pixel padding around text this.horizontal_text_buffer = 4; // text styling such as italic emphasis is not measured by the Canvas API, so padding is necessary this.background_size = 4; // padding around label for optional background box (TODO: make configurable?) } createCanvas () { this.canvas = document.createElement('canvas'); this.canvas.style.backgroundColor = 'transparent'; // render text on transparent background this.context = this.canvas.getContext('2d'); } resize (width, height) { this.canvas.width = width; this.canvas.height = height; this.context.clearRect(0, 0, width, height); } // Set font style params for canvas drawing setFont ({ font_css, fill, stroke, stroke_width, px_size, supersample }) { this.px_size = px_size; let ctx = this.context; let dpr = Utils.device_pixel_ratio * supersample; if (stroke && stroke_width > 0) { ctx.strokeStyle = stroke; ctx.lineWidth = stroke_width * dpr; } ctx.fillStyle = fill; ctx.font = font_css; ctx.miterLimit = 2; } async textSizes (tile_id, texts) { await FontManager.loadFonts(); return Task.add({ type: 'textSizes', run: this.processTextSizesTask.bind(this), texts, tile_id, cursor: { styles: Object.keys(texts), texts: null, style_idx: null, text_idx: null } }); } processTextSizesTask (task) { let { cursor, texts } = task; cursor.style_idx = cursor.style_idx || 0; while (cursor.style_idx < cursor.styles.length) { let style = cursor.styles[cursor.style_idx]; if (cursor.text_idx == null) { cursor.text_idx = 0; cursor.texts = Object.keys(texts[style]); } let text_infos = texts[style]; let first = true; while (cursor.text_idx < cursor.texts.length) { let text = cursor.texts[cursor.text_idx]; let text_info = text_infos[text]; let text_settings = text_info.text_settings; if (first) { this.setFont(text_settings); first = false; } // add size of full text string text_info.size = this.textSize(style, text, text_settings).size; // if text may curve, calculate per-segment as well if (text_settings.can_articulate) { let rtl = false; let bidi = false; if (isTextRTL(text)) { if (!isTextNeutral(text)) { bidi = true; } else { rtl = true; } } text_info.isRTL = rtl; text_info.no_curving = bidi || isTextCurveBlacklisted(text); // used in LabelLine to prevent curved labels text_info.vertical_buffer = this.vertical_text_buffer; text_info.segment_sizes = []; if (!text_info.no_curving) { let segments = splitLabelText(text, rtl, TextCanvas.cache); text_info.segments = segments; for (let i = 0; i < segments.length; i++){ text_info.segment_sizes.push(this.textSize(style, segments[i], text_settings).size); } } } cursor.text_idx++; if (!Task.shouldContinue(task)) { return false; } } cursor.text_idx = null; cursor.style_idx++; } Task.finish(task, texts); return true; } // Computes width and height of text based on current font style // Includes word wrapping, returns size info for whole text block and individual lines textSize(style, text, { transform, text_wrap, max_lines, stroke_width = 0, background_color, background_stroke_width = 0, background_width, underline_width = 0, supersample }) { // Check cache first TextCanvas.cache.text[style] = TextCanvas.cache.text[style] || {}; if (TextCanvas.cache.text[style][text]) { TextCanvas.cache.stats.text_hits++; return TextCanvas.cache.text[style][text]; } TextCanvas.cache.stats.text_misses++; TextCanvas.cache.text_count++; // Calc and store in cache const dpr = Utils.device_pixel_ratio * supersample; const str = this.applyTextTransform(text, transform); const ctx = this.context; const vertical_buffer = this.vertical_text_buffer * dpr; const horizontal_buffer = (stroke_width + this.horizontal_text_buffer) * dpr; background_width = background_width != null ? background_width : this.background_size; // apply default background width const background_size = (background_color || background_stroke_width) ? (background_width + background_stroke_width) * dpr : 0; const leading = (2 + underline_width + (underline_width ? (stroke_width + 1) : 0)) * dpr; // adjust for underline and text stroke const line_height = this.px_size + leading; // px_size already in device pixels // Parse string into series of lines if it exceeds the text wrapping value or contains line breaks // const multiline = MultiLine.parse(str, text_wrap, max_lines, line_height, ctx); let { width, height, lines } = MultiLine.parse(str, text_wrap, max_lines, line_height, ctx); width += background_size * 2; height += background_size * 2; let collision_size = [ width / dpr, height / dpr ]; let texture_size = [ width + 2 * horizontal_buffer, height + 2 * vertical_buffer ]; let logical_size = [ texture_size[0] / dpr, texture_size[1] / dpr, ]; // Returns lines (w/per-line info for drawing) and text's overall bounding box + canvas size TextCanvas.cache.text[style][text] = { lines, size: { collision_size, texture_size, logical_size, horizontal_buffer, vertical_buffer, dpr, line_height, background_size } }; return TextCanvas.cache.text[style][text]; } // Draw multiple lines of text drawTextMultiLine (lines, [x, y], size, text_settings, label_type) { const { dpr, collision_size, texture_size, line_height, horizontal_buffer, vertical_buffer } = size; // draw optional background box if (text_settings.background_color || text_settings.background_stroke_color) { const background_stroke_color = text_settings.background_stroke_color; const background_stroke_width = (text_settings.background_stroke_width || 0) * dpr; this.context.save(); if (text_settings.background_color) { this.context.fillStyle = text_settings.background_color; this.context.fillRect( // shift to "foreground" stroke texture for curved labels (separate stroke and fill textures) x + horizontal_buffer + (label_type === 'curved' ? texture_size[0] : 0) + background_stroke_width, y + vertical_buffer + background_stroke_width, dpr * collision_size[0] - background_stroke_width * 2, dpr * collision_size[1] - background_stroke_width * 2 ); } // optional stroke around background box if (background_stroke_color && background_stroke_width) { this.context.strokeStyle = background_stroke_color; this.context.lineWidth = background_stroke_width; this.context.strokeRect( // shift to "foreground" stroke texture for curved labels (separate stroke and fill textures) x + horizontal_buffer + (label_type === 'curved' ? texture_size[0] : 0) + background_stroke_width * 0.5, y + vertical_buffer + background_stroke_width * 0.5, dpr * collision_size[0] - background_stroke_width, dpr * collision_size[1] - background_stroke_width ); } this.context.restore(); } // draw text const underline_width = text_settings.underline_width || 0; const stroke_width = text_settings.stroke_width || 0; const voffset = underline_width ? // offset text position to account for underline and text stroke ((underline_width + stroke_width + 1) * 0.5 * dpr) : 0; let ty = y - voffset; for (let line_num=0; line_num < lines.length; line_num++) { let line = lines[line_num]; this.drawTextLine(line, [x, ty], size, text_settings, label_type); ty += line_height; } this.drawTextDebug([x, y], size, label_type); } // Draw single line of text at specified location, adjusting for buffer and baseline drawTextLine(line, [x, y], size, text_settings, type) { const { stroke, stroke_width, transform, align = 'center' } = text_settings; const { horizontal_buffer, vertical_buffer, texture_size, background_size, line_height, dpr } = size; const underline_width = (text_settings.underline_width || 0) * dpr; const text = this.applyTextTransform(line.text, transform); // Text alignment let tx; if (align === 'left') { tx = x + horizontal_buffer + background_size; } else if (align === 'center') { tx = x + texture_size[0] / 2 - line.width / 2; } else if (align === 'right') { tx = x + texture_size[0] - line.width - horizontal_buffer - background_size; } // In the absence of better Canvas TextMetrics (not supported by browsers yet), // 0.75 buffer produces a better approximate vertical centering of text const ty = y + vertical_buffer * 0.75 + line_height + background_size - underline_width * 0.5; // Draw stroke and fill separately for curved text. Offset stroke in texture atlas by shift. const shift = (stroke && stroke_width > 0 && type === 'curved') ? texture_size[0] : 0; // optional text underline if (underline_width) { this.context.save(); this.context.strokeStyle = this.context.fillStyle; this.context.lineWidth = underline_width; // adjust the underline to account for the text stroke const uy = ty + ((stroke_width * 0.5 + 2) * dpr) + this.context.lineWidth * 0.5; this.context.beginPath(); this.context.moveTo(tx + shift, uy); this.context.lineTo(tx + shift + line.width, uy); this.context.stroke(); this.context.restore(); } if (stroke && stroke_width > 0) { this.context.strokeText(text, tx + shift, ty); } this.context.fillText(text, tx, ty); } // Draw optional text debug boxes drawTextDebug ([x, y], size, label_type) { const { dpr, horizontal_buffer, vertical_buffer, texture_size, collision_size } = size; const line_width = 2; if (debugSettings.draw_label_collision_boxes) { this.context.save(); this.context.strokeStyle = 'blue'; this.context.lineWidth = line_width; this.context.strokeRect(x + horizontal_buffer, y + vertical_buffer, dpr * collision_size[0], dpr * collision_size[1]); if (label_type === 'curved'){ this.context.strokeRect(x + texture_size[0] + horizontal_buffer, y + vertical_buffer, dpr * collision_size[0], dpr * collision_size[1]); } this.context.restore(); } if (debugSettings.draw_label_texture_boxes) { this.context.save(); this.context.strokeStyle = 'green'; this.context.lineWidth = line_width; // stroke is applied internally, so the outer border is the edge of the texture this.context.strokeRect(x + line_width, y + line_width, texture_size[0] - 2 * line_width, texture_size[1] - 2 * line_width); if (label_type === 'curved') { this.context.strokeRect(x + line_width + texture_size[0], y + line_width, texture_size[0] - 2 * line_width, texture_size[1] - 2 * line_width); } this.context.restore(); } } rasterize (texts, textures, tile_id, texture_prefix, gl) { return Task.add({ type: 'rasterizeLabels', run: this.processRasterizeTask.bind(this), cancel: this.cancelRasterizeTask.bind(this), pause_factor: 2, // pause 2 frames when task run past allowed time user_moving_view: false, // don't run task when user is moving view texts, textures, texture_prefix, gl, tile_id, cursor: { styles: Object.keys(texts), texts: null, style_idx: 0, text_idx: null, texture_idx: 0, texture_resize: true, texture_names: [] } }); } processRasterizeTask (task) { let { cursor, texts, textures } = task; let texture; // Rasterize one texture at a time, so we only have to keep one canvas in memory (they can be large) while (cursor.texture_idx < task.textures.length) { texture = textures[cursor.texture_idx]; if (cursor.texture_resize) { cursor.texture_resize = false; this.resize(...texture.texture_size); } while (cursor.style_idx < cursor.styles.length) { let style = cursor.styles[cursor.style_idx]; if (cursor.text_idx == null) { cursor.text_idx = 0; cursor.texts = Object.keys(texts[style]); } let text_infos = texts[style]; let first = true; while (cursor.text_idx < cursor.texts.length) { let text = cursor.texts[cursor.text_idx]; let text_info = text_infos[text]; let text_settings = text_info.text_settings; // set font on first occurence of new font style if (first) { this.setFont(text_settings); first = false; } if (text_settings.can_articulate) { text_info.texcoords = text_info.texcoords || {}; for (let t = 0; t < text_info.type.length; t++) { let type = text_info.type[t]; if (type === 'straight') { // Only render for current texture if (text_info.textures[t] !== cursor.texture_idx) { continue; } let word = (text_info.isRTL) ? text.split().reverse().join() : text; let cache = texture.texcoord_cache[style][word]; let texcoord; if (cache.texcoord) { texcoord = cache.texcoord; } else { let texture_position = cache.texture_position; let { size, lines } = this.textSize(style, word, text_settings); this.drawTextMultiLine(lines, texture_position, size, text_settings, type); texcoord = Texture.getTexcoordsForSprite( texture_position, size.texture_size, texture.texture_size ); cache.texcoord = texcoord; } text_info.texcoords[type] = { texcoord, texture_id: cache.texture_id }; } else if (type === 'curved') { let words = text_info.segments; text_info.texcoords.curved = text_info.texcoords.curved || []; text_info.texcoords_stroke = text_info.texcoords_stroke || []; for (let w = 0; w < words.length; w++){ // Only render for current texture if (text_info.textures[t][w] !== cursor.texture_idx) { continue; } let word = words[w]; let cache = texture.texcoord_cache[style][word]; let texcoord; let texcoord_stroke; if (cache.texcoord){ texcoord = cache.texcoord; texcoord_stroke = cache.texcoord_stroke; text_info.texcoords_stroke.push(texcoord_stroke); } else { let texture_position = cache.texture_position; let { size, lines } = this.textSize(style, word, text_settings); this.drawTextMultiLine(lines, texture_position, size, text_settings, type); texcoord = Texture.getTexcoordsForSprite( texture_position, size.texture_size, texture.texture_size ); let texture_position_stroke = [ texture_position[0] + size.texture_size[0], texture_position[1] ]; texcoord_stroke = Texture.getTexcoordsForSprite( texture_position_stroke, size.texture_size, texture.texture_size ); cache.texcoord = texcoord; cache.texcoord_stroke = texcoord_stroke; // NB: texture_id is the same between stroke and fill, so it's not duplicated here text_info.texcoords_stroke.push(texcoord_stroke); } text_info.texcoords.curved.push({ texcoord, texture_id: cache.texture_id }); } } } } else { let lines = this.textSize(style, text, text_settings).lines; const aligned_text_settings = { ...text_settings }; for (let align in text_info.align) { // Only render for current texture if (text_info.align[align].texture_id !== cursor.texture_idx) { continue; } aligned_text_settings.align = align; this.drawTextMultiLine(lines, text_info.align[align].texture_position, text_info.size, aligned_text_settings); text_info.align[align].texcoords = Texture.getTexcoordsForSprite( text_info.align[align].texture_position, text_info.size.texture_size, texture.texture_size ); } } cursor.text_idx++; if (!Task.shouldContinue(task)) { return false; } } cursor.text_idx = null; cursor.style_idx++; } // Create GL texture (canvas element will be reused for next texture) let tname = task.texture_prefix + cursor.texture_idx; Texture.create(task.gl, tname, { element: this.canvas, filtering: 'linear', UNPACK_PREMULTIPLY_ALPHA_WEBGL: true }); Texture.retain(tname); cursor.texture_names.push(tname); cursor.texture_idx++; cursor.texture_resize = true; cursor.style_idx = 0; } Task.finish(task, cursor.texture_names); return true; } // Free any textures that have been allocated part-way through label rasterization for a tile cancelRasterizeTask (task) { log('trace', `RasterizeTask: release textures [${task.cursor.texture_names.join(', ')}]`); task.cursor.texture_names.forEach(t => Texture.release(t)); } // Place text labels within an atlas of the given max size setTextureTextPositions (texts, max_texture_size) { let texture = { cx: 0, cy: 0, width: 0, height: 0, column_width: 0, texture_id: 0, texcoord_cache: {} }, textures = []; for (let style in texts) { let text_infos = texts[style]; for (let text in text_infos) { let text_info = text_infos[text]; let texture_position; if (text_info.text_settings.can_articulate) { text_info.textures = []; texture.texcoord_cache[style] = texture.texcoord_cache[style] || {}; for (let t = 0; t < text_info.type.length; t++) { let type = text_info.type[t]; if (type === 'straight') { let word = (text_info.isRTL) ? text.split().reverse().join() : text; if (!texture.texcoord_cache[style][word]) { let size = text_info.size.texture_size; texture_position = this.placeText(size[0], size[1], style, texture, textures, max_texture_size); texture.texcoord_cache[style][word] = { texture_id: texture.texture_id, texture_position }; } text_info.textures[t] = texture.texture_id; } else if (type === 'curved') { text_info.textures[t] = []; for (let w = 0; w < text_info.segment_sizes.length; w++) { let word = text_info.segments[w]; if (!texture.texcoord_cache[style][word]) { let size = text_info.segment_sizes[w].texture_size; let width = 2 * size[0]; // doubled to account for side-by-side rendering of fill and stroke texture_position = this.placeText(width, size[1], style, texture, textures, max_texture_size); texture.texcoord_cache[style][word] = { texture_id: texture.texture_id, texture_position }; } text_info.textures[t].push(texture.texture_id); } } } } else { // rendered size is same for all alignments let size = text_info.size.texture_size; // but each alignment needs to be rendered separately for (let align in text_info.align) { texture_position = this.placeText (size[0], size[1], style, texture, textures, max_texture_size); text_info.align[align].texture_id = texture.texture_id; text_info.align[align].texture_position = texture_position; } } } } // save final texture if (texture.column_width > 0 && texture.height > 0) { textures[texture.texture_id] = { texture_size: [texture.width, texture.height], texcoord_cache: texture.texcoord_cache }; } // return computed texture sizes and UV cache return textures; } // Place text sprite in texture atlas, enlarging current texture, or starting new one if max texture size reached placeText (text_width, text_height, style, texture, textures, max_texture_size) { let texture_position; // TODO: what if first label is wider than entire max texture? if (texture.cy + text_height > max_texture_size) { // start new column texture.cx += texture.column_width; texture.cy = 0; texture.column_width = text_width; } else { // expand current column texture.column_width = Math.max(texture.column_width, text_width); } if (texture.cx + texture.column_width <= max_texture_size) { // add label to current texture texture_position = [texture.cx, texture.cy]; texture.cy += text_height; // expand texture if needed texture.height = Math.max(texture.height, texture.cy); texture.width = Math.max(texture.width, texture.cx + texture.column_width); } else { // start new texture // save size and cache of last texture textures[texture.texture_id] = { texture_size: [texture.width, texture.height], texcoord_cache: texture.texcoord_cache }; texture.texcoord_cache = {}; // reset cache texture.texcoord_cache[style] = {}; texture.texture_id++; texture.cx = 0; texture.cy = text_height; texture.column_width = text_width; texture.width = text_width; texture.height = text_height; texture_position = [0, 0]; // TODO: allocate zero array once } return texture_position; } // Called before rasterization applyTextTransform (text, transform) { if (transform === 'capitalize') { return text.replace(/\w\S*/g, function (txt) { return txt.charAt(0).toUpperCase() + txt.substr(1); }); } else if (transform === 'uppercase') { return text.toUpperCase(); } else if (transform === 'lowercase') { return text.toLowerCase(); } return text; } // Convert font CSS-style size ('12px', '14pt', '1.5em', etc.) to pixel size (adjusted for device pixel ratio) // Defaults units to pixels if not specified static fontPixelSize (size) { if (size == null) { return; } size = (typeof size === 'string') ? size : String(size); // need a string for regex let [, px_size, units] = size.match(TextCanvas.font_size_re) || []; units = units || 'px'; if (units === 'em') { px_size *= 16; } else if (units === 'pt') { px_size /= 0.75; } else if (units === '%') { px_size /= 6.25; } px_size = StyleParser.parsePositiveNumber(px_size); px_size *= Utils.device_pixel_ratio; return px_size; } static pruneTextCache () { if (TextCanvas.cache.text_count > TextCanvas.cache.text_count_max) { TextCanvas.cache.text = {}; TextCanvas.cache.text_count = 0; log('debug', 'TextCanvas: pruning text cache'); } if (Object.keys(TextCanvas.cache.segment).length > TextCanvas.cache.segment_count_max) { TextCanvas.cache.segment = {}; log('debug', 'TextCanvas: pruning segment cache'); } } } // Extract font size and units TextCanvas.font_size_re = /((?:[0-9]*\.)?[0-9]+)\s*(px|pt|em|%)?/; // Cache sizes of rendered text TextCanvas.cache = { text: {}, // size and line parsing, by text style, then text string text_count: 0, // current size of cache (measured as # of entries) text_count_max: 2000, // prune cache when it exceeds this size segment: {}, // segmentation of text (by run of characters or grapheme clusters), by text string segment_count_max: 2000, // prune cache when it exceeds this size stats: { text_hits: 0, text_misses: 0, segment_hits: 0, segment_misses: 0 } };