mapbox-gl
Version:
A WebGL interactive maps library
367 lines (299 loc) • 11.2 kB
JavaScript
const scriptDetection = require('../util/script_detection');
const verticalizePunctuation = require('../util/verticalize_punctuation');
const rtlTextPlugin = require('../source/rtl_text_plugin');
const WritingMode = {
horizontal: 1,
vertical: 2
};
module.exports = {
shapeText: shapeText,
shapeIcon: shapeIcon,
WritingMode: WritingMode
};
// The position of a glyph relative to the text's anchor point.
function PositionedGlyph(codePoint, x, y, glyph, angle) {
this.codePoint = codePoint;
this.x = x;
this.y = y;
this.glyph = glyph || null;
this.angle = angle;
}
// A collection of positioned glyphs and some metadata
function Shaping(positionedGlyphs, text, top, bottom, left, right, writingMode) {
this.positionedGlyphs = positionedGlyphs;
this.text = text;
this.top = top;
this.bottom = bottom;
this.left = left;
this.right = right;
this.writingMode = writingMode;
}
function breakLines(text, lineBreakPoints) {
const lines = [];
let start = 0;
for (const lineBreak of lineBreakPoints) {
lines.push(text.substring(start, lineBreak));
start = lineBreak;
}
if (start < text.length) {
lines.push(text.substring(start, text.length));
}
return lines;
}
function shapeText(text, glyphs, maxWidth, lineHeight, textAnchor, textJustify, spacing, translate, verticalHeight, writingMode) {
let logicalInput = text.trim();
if (writingMode === WritingMode.vertical) logicalInput = verticalizePunctuation(logicalInput);
const positionedGlyphs = [];
const shaping = new Shaping(positionedGlyphs, logicalInput, translate[1], translate[1], translate[0], translate[0], writingMode);
let lines;
if (rtlTextPlugin.processBidirectionalText) {
lines = rtlTextPlugin.processBidirectionalText(logicalInput, determineLineBreaks(logicalInput, spacing, maxWidth, glyphs));
} else {
lines = breakLines(logicalInput, determineLineBreaks(logicalInput, spacing, maxWidth, glyphs));
}
shapeLines(shaping, glyphs, lines, lineHeight, textAnchor, textJustify, translate, writingMode, spacing, verticalHeight);
if (!positionedGlyphs.length)
return false;
return shaping;
}
const whitespace = {
0x09: true, // tab
0x0a: true, // newline
0x0b: true, // vertical tab
0x0c: true, // form feed
0x0d: true, // carriage return
0x20: true, // space
};
const breakable = {
0x0a: true, // newline
0x20: true, // space
0x26: true, // ampersand
0x28: true, // left parenthesis
0x29: true, // right parenthesis
0x2b: true, // plus sign
0x2d: true, // hyphen-minus
0x2f: true, // solidus
0xad: true, // soft hyphen
0xb7: true, // middle dot
0x200b: true, // zero-width space
0x2010: true, // hyphen
0x2013: true, // en dash
0x2027: true // interpunct
// Many other characters may be reasonable breakpoints
// Consider "neutral orientation" characters at scriptDetection.charHasNeutralVerticalOrientation
// See https://github.com/mapbox/mapbox-gl-js/issues/3658
};
function determineAverageLineWidth(logicalInput, spacing, maxWidth, glyphs) {
let totalWidth = 0;
for (const index in logicalInput) {
const glyph = glyphs[logicalInput.charCodeAt(index)];
if (!glyph)
continue;
totalWidth += glyph.advance + spacing;
}
const lineCount = Math.max(1, Math.ceil(totalWidth / maxWidth));
return totalWidth / lineCount;
}
function calculateBadness(lineWidth, targetWidth, penalty, isLastBreak) {
const raggedness = Math.pow(lineWidth - targetWidth, 2);
if (isLastBreak) {
// Favor finals lines shorter than average over longer than average
if (lineWidth < targetWidth) {
return raggedness / 2;
} else {
return raggedness * 2;
}
}
return raggedness + Math.abs(penalty) * penalty;
}
function calculatePenalty(codePoint, nextCodePoint) {
let penalty = 0;
// Force break on newline
if (codePoint === 0x0a) {
penalty -= 10000;
}
// Penalize open parenthesis at end of line
if (codePoint === 0x28 || codePoint === 0xff08) {
penalty += 50;
}
// Penalize close parenthesis at beginning of line
if (nextCodePoint === 0x29 || nextCodePoint === 0xff09) {
penalty += 50;
}
return penalty;
}
function evaluateBreak(breakIndex, breakX, targetWidth, potentialBreaks, penalty, isLastBreak) {
// We could skip evaluating breaks where the line length (breakX - priorBreak.x) > maxWidth
// ...but in fact we allow lines longer than maxWidth (if there's no break points)
// ...and when targetWidth and maxWidth are close, strictly enforcing maxWidth can give
// more lopsided results.
let bestPriorBreak = null;
let bestBreakBadness = calculateBadness(breakX, targetWidth, penalty, isLastBreak);
for (const potentialBreak of potentialBreaks) {
const lineWidth = breakX - potentialBreak.x;
const breakBadness =
calculateBadness(lineWidth, targetWidth, penalty, isLastBreak) + potentialBreak.badness;
if (breakBadness <= bestBreakBadness) {
bestPriorBreak = potentialBreak;
bestBreakBadness = breakBadness;
}
}
return {
index: breakIndex,
x: breakX,
priorBreak: bestPriorBreak,
badness: bestBreakBadness
};
}
function leastBadBreaks(lastLineBreak) {
if (!lastLineBreak) {
return [];
}
return leastBadBreaks(lastLineBreak.priorBreak).concat(lastLineBreak.index);
}
function determineLineBreaks(logicalInput, spacing, maxWidth, glyphs) {
if (!maxWidth)
return [];
if (!logicalInput)
return [];
const potentialLineBreaks = [];
const targetWidth = determineAverageLineWidth(logicalInput, spacing, maxWidth, glyphs);
let currentX = 0;
for (let i = 0; i < logicalInput.length; i++) {
const codePoint = logicalInput.charCodeAt(i);
const glyph = glyphs[codePoint];
if (glyph && !whitespace[codePoint])
currentX += glyph.advance + spacing;
// Ideographic characters, spaces, and word-breaking punctuation that often appear without
// surrounding spaces.
if ((i < logicalInput.length - 1) &&
(breakable[codePoint] ||
scriptDetection.charAllowsIdeographicBreaking(codePoint))) {
potentialLineBreaks.push(
evaluateBreak(
i + 1,
currentX,
targetWidth,
potentialLineBreaks,
calculatePenalty(codePoint, logicalInput.charCodeAt(i + 1)),
false));
}
}
return leastBadBreaks(
evaluateBreak(
logicalInput.length,
currentX,
targetWidth,
potentialLineBreaks,
0,
true));
}
function getAnchorAlignment(textAnchor) {
let horizontalAlign = 0.5, verticalAlign = 0.5;
switch (textAnchor) {
case 'right':
case 'top-right':
case 'bottom-right':
horizontalAlign = 1;
break;
case 'left':
case 'top-left':
case 'bottom-left':
horizontalAlign = 0;
break;
}
switch (textAnchor) {
case 'bottom':
case 'bottom-right':
case 'bottom-left':
verticalAlign = 1;
break;
case 'top':
case 'top-right':
case 'top-left':
verticalAlign = 0;
break;
}
return { horizontalAlign: horizontalAlign, verticalAlign: verticalAlign };
}
function shapeLines(shaping, glyphs, lines, lineHeight, textAnchor, textJustify, translate, writingMode, spacing, verticalHeight) {
// the y offset *should* be part of the font metadata
const yOffset = -17;
let x = 0;
let y = yOffset;
let maxLineLength = 0;
const positionedGlyphs = shaping.positionedGlyphs;
const justify =
textJustify === 'right' ? 1 :
textJustify === 'left' ? 0 : 0.5;
for (const i in lines) {
const line = lines[i].trim();
if (!line.length) {
y += lineHeight; // Still need a line feed after empty line
continue;
}
const lineStartIndex = positionedGlyphs.length;
for (let i = 0; i < line.length; i++) {
const codePoint = line.charCodeAt(i);
const glyph = glyphs[codePoint];
if (!glyph) continue;
if (!scriptDetection.charHasUprightVerticalOrientation(codePoint) || writingMode === WritingMode.horizontal) {
positionedGlyphs.push(new PositionedGlyph(codePoint, x, y, glyph, 0));
x += glyph.advance + spacing;
} else {
positionedGlyphs.push(new PositionedGlyph(codePoint, x, 0, glyph, -Math.PI / 2));
x += verticalHeight + spacing;
}
}
// Only justify if we placed at least one glyph
if (positionedGlyphs.length !== lineStartIndex) {
const lineLength = x - spacing;
maxLineLength = Math.max(lineLength, maxLineLength);
justifyLine(positionedGlyphs, glyphs, lineStartIndex, positionedGlyphs.length - 1, justify);
}
x = 0;
y += lineHeight;
}
const anchorPosition = getAnchorAlignment(textAnchor);
align(positionedGlyphs, justify, anchorPosition.horizontalAlign, anchorPosition.verticalAlign, maxLineLength, lineHeight, lines.length);
// Calculate the bounding box
const height = lines.length * lineHeight;
shaping.top += -anchorPosition.verticalAlign * height;
shaping.bottom = shaping.top + height;
shaping.left += -anchorPosition.horizontalAlign * maxLineLength;
shaping.right = shaping.left + maxLineLength;
}
// justify right = 1, left = 0, center = 0.5
function justifyLine(positionedGlyphs, glyphs, start, end, justify) {
if (!justify)
return;
const lastAdvance = glyphs[positionedGlyphs[end].codePoint].advance;
const lineIndent = (positionedGlyphs[end].x + lastAdvance) * justify;
for (let j = start; j <= end; j++) {
positionedGlyphs[j].x -= lineIndent;
}
}
function align(positionedGlyphs, justify, horizontalAlign, verticalAlign, maxLineLength, lineHeight, lineCount) {
const shiftX = (justify - horizontalAlign) * maxLineLength;
const shiftY = (-verticalAlign * lineCount + 0.5) * lineHeight;
for (let j = 0; j < positionedGlyphs.length; j++) {
positionedGlyphs[j].x += shiftX;
positionedGlyphs[j].y += shiftY;
}
}
function shapeIcon(image, iconOffset) {
const dx = iconOffset[0];
const dy = iconOffset[1];
const x1 = dx - image.displaySize[0] / 2;
const x2 = x1 + image.displaySize[0];
const y1 = dy - image.displaySize[1] / 2;
const y2 = y1 + image.displaySize[1];
return new PositionedIcon(image, y1, y2, x1, x2);
}
function PositionedIcon(image, top, bottom, left, right) {
this.image = image;
this.top = top;
this.bottom = bottom;
this.left = left;
this.right = right;
}