UNPKG

maplibre-gl

Version:

BSD licensed community fork of mapbox-gl, a WebGL interactive maps library

409 lines (357 loc) 13.7 kB
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; } }