tangram
Version:
WebGL Maps for Vector Tiles
735 lines (618 loc) • 31.2 kB
JavaScript
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 }
};