maplibre-gl
Version:
BSD licensed community fork of mapbox-gl, a WebGL interactive maps library
409 lines (357 loc) • 13.7 kB
text/typescript
import type {Formatted, FormattedSection, VerticalAlign} from '@maplibre/maplibre-gl-style-spec';
import ONE_EM from './one_em';
import type {ImagePosition} from '../render/image_atlas';
import type {StyleGlyph} from '../style/style_glyph';
import {verticalizePunctuation} from '../util/verticalize_punctuation';
import {charIsWhitespace} from '../util/script_detection';
import {codePointAllowsIdeographicBreaking} from '../util/unicode_properties.g';
import {warnOnce} from '../util/util';
export type TextSectionOptions = {
scale: number;
verticalAlign: VerticalAlign;
fontStack: string;
};
export type ImageSectionOptions = {
scale: number;
verticalAlign: VerticalAlign;
imageName: string;
};
export type SectionOptions = TextSectionOptions | ImageSectionOptions;
// Max number of images in label is 6401 U+E000–U+F8FF that covers
// Basic Multilingual Plane Unicode Private Use Area (PUA).
const PUAbegin = 0xE000;
const PUAend = 0xF8FF;
type Break = {
index: number;
x: number;
priorBreak: Break;
badness: number;
};
// using computed properties due to https://github.com/facebook/flow/issues/380
/* eslint no-useless-computed-key: 0 */
const breakable: {
[_: number]: boolean;
} = {
[0x0a]: true, // newline
[0x20]: true, // space
[0x26]: true, // ampersand
[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 in codePointHasNeutralVerticalOrientation in unicode_properties
// See https://github.com/mapbox/mapbox-gl-js/issues/3658
};
// Allow breaks depending on the following character
const breakableBefore: {
[_: number]: boolean;
} = {
[0x28]: true, // left parenthesis
};
function getGlyphAdvance(
codePoint: number,
section: SectionOptions,
glyphMap: {
[_: string]: {
[_: number]: StyleGlyph;
};
},
imagePositions: {[_: string]: ImagePosition},
spacing: number,
layoutTextSize: number
): number {
if ('fontStack' in section) {
const positions = glyphMap[section.fontStack];
const glyph = positions?.[codePoint];
if (!glyph) return 0;
return glyph.metrics.advance * section.scale + spacing;
} else {
const imagePosition = imagePositions[section.imageName];
if (!imagePosition) return 0;
return imagePosition.displaySize[0] * section.scale * ONE_EM / layoutTextSize + spacing;
}
}
function calculateBadness(lineWidth: number,
targetWidth: number,
penalty: number,
isLastBreak: boolean) {
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: number, nextCodePoint: number, penalizableIdeographicBreak: boolean) {
let penalty = 0;
// Force break on newline
if (codePoint === 0x0a) {
penalty -= 10000;
}
// Penalize breaks between characters that allow ideographic breaking because
// they are less preferable than breaks at spaces (or zero width spaces).
if (penalizableIdeographicBreak) {
penalty += 150;
}
// 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: number,
breakX: number,
targetWidth: number,
potentialBreaks: Break[],
penalty: number,
isLastBreak: boolean
): Break {
// 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: Break = 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?: Break | null): number[] {
if (!lastLineBreak) {
return [];
}
return leastBadBreaks(lastLineBreak.priorBreak).concat(lastLineBreak.index);
}
export class TaggedString {
text: string;
sections: SectionOptions[];
/** Maps each character in `text` to its corresponding entry in `sections`. */
sectionIndex: number[];
imageSectionID: number | null;
constructor(text: string = '', sections: SectionOptions[] = [], sectionIndex: number[] = []) {
this.text = text;
this.sections = sections;
this.sectionIndex = sectionIndex;
this.imageSectionID = null;
}
static fromFeature(text: Formatted, defaultFontStack: string) {
const result = new TaggedString();
for (const section of text.sections) {
if (!section.image) {
result.addTextSection(section, defaultFontStack);
} else {
result.addImageSection(section);
}
}
return result;
}
length(): number {
return [...this.text].length;
}
getSection(index: number): SectionOptions {
return this.sections[this.sectionIndex[index]];
}
getSectionIndex(index: number): number {
return this.sectionIndex[index];
}
verticalizePunctuation() {
this.text = verticalizePunctuation(this.text);
}
/**
* Returns whether the text contains zero-width spaces.
*
* Some tilesets such as Mapbox Streets insert ZWSPs as hints for line
* breaking in CJK text.
*/
hasZeroWidthSpaces(): boolean {
return this.text.includes('\u200b');
}
trim() {
const leadingWhitespace = this.text.match(/^\s*/);
const leadingLength = leadingWhitespace ? leadingWhitespace[0].length : 0;
// Require a preceding non-space character to avoid overlapping leading and trailing matches.
const trailingWhitespace = this.text.match(/\S\s*$/);
const trailingLength = trailingWhitespace ? trailingWhitespace[0].length - 1 : 0;
this.text = this.text.substring(leadingLength, this.text.length - trailingLength);
this.sectionIndex = this.sectionIndex.slice(leadingLength, this.sectionIndex.length - trailingLength);
}
substring(start: number, end: number): TaggedString {
const text = [...this.text].slice(start, end).join('');
const sectionIndex = this.sectionIndex.slice(start, end);
return new TaggedString(text, this.sections, sectionIndex);
}
/**
* Converts a UTF-16 character index to a UTF-16 code unit (JavaScript character index).
*/
toCodeUnitIndex(unicodeIndex: number): number {
return [...this.text].slice(0, unicodeIndex).join('').length;
}
toString(): string {
return this.text;
}
getMaxScale() {
return this.sectionIndex.reduce((max, index) => Math.max(max, this.sections[index].scale), 0);
}
getMaxImageSize(imagePositions: {[_: string]: ImagePosition}): {
maxImageWidth: number;
maxImageHeight: number;
} {
let maxImageWidth = 0;
let maxImageHeight = 0;
for (let i = 0; i < this.length(); i++) {
const section = this.getSection(i);
if ('imageName' in section) {
const imagePosition = imagePositions[section.imageName];
if (!imagePosition) continue;
const size = imagePosition.displaySize;
maxImageWidth = Math.max(maxImageWidth, size[0]);
maxImageHeight = Math.max(maxImageHeight, size[1]);
}
}
return {maxImageWidth, maxImageHeight};
}
addTextSection(section: FormattedSection, defaultFontStack: string) {
this.text += section.text;
this.sections.push({
scale: section.scale || 1,
verticalAlign: section.verticalAlign || 'bottom',
fontStack: section.fontStack || defaultFontStack,
} as TextSectionOptions);
const index = this.sections.length - 1;
this.sectionIndex.push(...[...section.text].map(() => index));
}
addImageSection(section: FormattedSection) {
const imageName = section.image ? section.image.name : '';
if (imageName.length === 0) {
warnOnce('Can\'t add FormattedSection with an empty image.');
return;
}
const nextImageSectionCharCode = this.getNextImageSectionCharCode();
if (!nextImageSectionCharCode) {
warnOnce(`Reached maximum number of images ${PUAend - PUAbegin + 2}`);
return;
}
this.text += String.fromCharCode(nextImageSectionCharCode);
this.sections.push({
scale: 1,
verticalAlign: section.verticalAlign || 'bottom',
imageName,
} as ImageSectionOptions);
this.sectionIndex.push(this.sections.length - 1);
}
getNextImageSectionCharCode(): number | null {
if (!this.imageSectionID) {
this.imageSectionID = PUAbegin;
return this.imageSectionID;
}
if (this.imageSectionID >= PUAend) return null;
return ++this.imageSectionID;
}
determineLineBreaks(
spacing: number,
maxWidth: number,
glyphMap: {
[_: string]: {
[_: number]: StyleGlyph;
};
},
imagePositions: {[_: string]: ImagePosition},
layoutTextSize: number
): number[] {
const potentialLineBreaks = [];
const targetWidth = this.determineAverageLineWidth(spacing, maxWidth, glyphMap, imagePositions, layoutTextSize);
const hasZeroWidthSpaces = this.hasZeroWidthSpaces();
let currentX = 0;
let i = 0;
const chars = this.text[Symbol.iterator]();
let char = chars.next();
const nextChars = this.text[Symbol.iterator]();
nextChars.next();
let nextChar = nextChars.next();
const nextNextChars = this.text[Symbol.iterator]();
nextNextChars.next();
nextNextChars.next();
let nextNextChar = nextNextChars.next();
while (!char.done) {
const section = this.getSection(i);
const codePoint = char.value.codePointAt(0);
if (!charIsWhitespace(codePoint)) currentX += getGlyphAdvance(codePoint, section, glyphMap, imagePositions, spacing, layoutTextSize);
// Ideographic characters, spaces, and word-breaking punctuation that often appear without
// surrounding spaces.
if (!nextChar.done) {
const ideographicBreak = codePointAllowsIdeographicBreaking(codePoint);
const nextCodePoint = nextChar.value.codePointAt(0);
if (breakable[codePoint] || ideographicBreak || 'imageName' in section || (!nextNextChar.done && breakableBefore[nextCodePoint])) {
potentialLineBreaks.push(
evaluateBreak(
i + 1,
currentX,
targetWidth,
potentialLineBreaks,
calculatePenalty(codePoint, nextCodePoint, ideographicBreak && hasZeroWidthSpaces),
false));
}
}
i++;
char = chars.next();
nextChar = nextChars.next();
nextNextChar = nextNextChars.next();
}
return leastBadBreaks(
evaluateBreak(
this.length(),
currentX,
targetWidth,
potentialLineBreaks,
0,
true));
}
determineAverageLineWidth(
spacing: number,
maxWidth: number,
glyphMap: {
[_: string]: {
[_: number]: StyleGlyph;
};
},
imagePositions: {[_: string]: ImagePosition},
layoutTextSize: number) {
let totalWidth = 0;
let index = 0;
for (const char of this.text) {
const section = this.getSection(index);
totalWidth += getGlyphAdvance(char.codePointAt(0), section, glyphMap, imagePositions, spacing, layoutTextSize);
index++;
}
const lineCount = Math.max(1, Math.ceil(totalWidth / maxWidth));
return totalWidth / lineCount;
}
}