UNPKG

maplibre-gl

Version:

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

667 lines (587 loc) 22.9 kB
import { codePointHasUprightVerticalOrientation } from '../util/unicode_properties.g'; import { charIsWhitespace, charInComplexShapingScript } from '../util/script_detection'; import {rtlWorkerPlugin} from '../source/rtl_text_plugin_worker'; import ONE_EM from './one_em'; import {TaggedString, type TextSectionOptions, type ImageSectionOptions} from './tagged_string'; import type {StyleGlyph, GlyphMetrics} from '../style/style_glyph'; import {GLYPH_PBF_BORDER} from '../style/parse_glyph_pbf'; import {TextFit} from '../style/style_image'; import type {ImagePosition} from '../render/image_atlas'; import {IMAGE_PADDING} from '../render/image_atlas'; import type {Rect, GlyphPosition} from '../render/glyph_atlas'; import type {Formatted, VerticalAlign} from '@maplibre/maplibre-gl-style-spec'; enum WritingMode { none = 0, horizontal = 1, vertical = 2, horizontalOnly = 3 } const SHAPING_DEFAULT_OFFSET = -17; export {shapeText, shapeIcon, applyTextFit, fitIconToText, getAnchorAlignment, WritingMode, SHAPING_DEFAULT_OFFSET}; // The position of a glyph relative to the text's anchor point. export type PositionedGlyph = { glyph: number; imageName: string | null; x: number; y: number; vertical: boolean; scale: number; fontStack: string; sectionIndex: number; metrics: GlyphMetrics; rect: Rect | null; }; export type PositionedLine = { positionedGlyphs: Array<PositionedGlyph>; lineOffset: number; }; // A collection of positioned glyphs and some metadata export type Shaping = { positionedLines: Array<PositionedLine>; top: number; bottom: number; left: number; right: number; writingMode: WritingMode.horizontal | WritingMode.vertical; text: string; iconsInText: boolean; verticalizable: boolean; }; type ShapingSectionAttributes = { rect: Rect | null; metrics: GlyphMetrics; baselineOffset: number; imageOffset?: number; }; type LineShapingSize = { verticalLineContentWidth: number; horizontalLineContentHeight: number; }; function isEmpty(positionedLines: Array<PositionedLine>) { for (const line of positionedLines) { if (line.positionedGlyphs.length !== 0) { return false; } } return true; } export type SymbolAnchor = 'center' | 'left' | 'right' | 'top' | 'bottom' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; export type TextJustify = 'left' | 'center' | 'right'; function breakLines(input: TaggedString, lineBreakPoints: Array<number>): Array<TaggedString> { const lines = []; let start = 0; for (const lineBreak of lineBreakPoints) { lines.push(input.substring(start, lineBreak)); start = lineBreak; } if (start < input.length()) { lines.push(input.substring(start, input.length())); } return lines; } function shapeText( text: Formatted, glyphMap: { [_: string]: { [_: number]: StyleGlyph; }; }, glyphPositions: { [_: string]: { [_: number]: GlyphPosition; }; }, imagePositions: {[_: string]: ImagePosition}, defaultFontStack: string, maxWidth: number, lineHeight: number, textAnchor: SymbolAnchor, textJustify: TextJustify, spacing: number, translate: [number, number], writingMode: WritingMode.horizontal | WritingMode.vertical, allowVerticalPlacement: boolean, layoutTextSize: number, layoutTextSizeThisZoom: number ): Shaping | false { const logicalInput = TaggedString.fromFeature(text, defaultFontStack); if (writingMode === WritingMode.vertical) { logicalInput.verticalizePunctuation(); } let lines: Array<TaggedString>; let lineBreaks = logicalInput.determineLineBreaks(spacing, maxWidth, glyphMap, imagePositions, layoutTextSize); const {processBidirectionalText, processStyledBidirectionalText} = rtlWorkerPlugin; if (processBidirectionalText && logicalInput.sections.length === 1) { // Bidi doesn't have to be style-aware lines = []; // ICU operates on code units. lineBreaks = lineBreaks.map(index => logicalInput.toCodeUnitIndex(index)); const untaggedLines = processBidirectionalText(logicalInput.toString(), lineBreaks); for (const line of untaggedLines) { const sectionIndex = [...line].map(() => 0); lines.push(new TaggedString(line, logicalInput.sections, sectionIndex)); } } else if (processStyledBidirectionalText) { // Need version of mapbox-gl-rtl-text with style support for combining RTL text // with formatting lines = []; // ICU operates on code units. lineBreaks = lineBreaks.map(index => logicalInput.toCodeUnitIndex(index)); // Convert character-based section index to be based on code units. let i = 0; const sectionIndex = []; for (const char of logicalInput.text) { sectionIndex.push(...Array(char.length).fill(logicalInput.sectionIndex[i])); i++; } const processedLines = processStyledBidirectionalText(logicalInput.text, sectionIndex, lineBreaks); for (const line of processedLines) { const sectionIndex = []; let elapsedChars = ''; for (const char of line[0]) { sectionIndex.push(line[1][elapsedChars.length]); elapsedChars += char; } lines.push(new TaggedString(line[0], logicalInput.sections, sectionIndex)); } } else { lines = breakLines(logicalInput, lineBreaks); } const positionedLines = []; const shaping = { positionedLines, text: logicalInput.toString(), top: translate[1], bottom: translate[1], left: translate[0], right: translate[0], writingMode, iconsInText: false, verticalizable: false }; shapeLines(shaping, glyphMap, glyphPositions, imagePositions, lines, lineHeight, textAnchor, textJustify, writingMode, spacing, allowVerticalPlacement, layoutTextSizeThisZoom); if (isEmpty(positionedLines)) return false; return shaping; } function getAnchorAlignment(anchor: SymbolAnchor) { let horizontalAlign = 0.5, verticalAlign = 0.5; switch (anchor) { case 'right': case 'top-right': case 'bottom-right': horizontalAlign = 1; break; case 'left': case 'top-left': case 'bottom-left': horizontalAlign = 0; break; } switch (anchor) { case 'bottom': case 'bottom-right': case 'bottom-left': verticalAlign = 1; break; case 'top': case 'top-right': case 'top-left': verticalAlign = 0; break; } return {horizontalAlign, verticalAlign}; } function calculateLineContentSize( imagePositions: {[_: string]: ImagePosition}, line: TaggedString, layoutTextSizeFactor: number ): LineShapingSize { const maxGlyphSize = line.getMaxScale() * ONE_EM; const {maxImageWidth, maxImageHeight} = line.getMaxImageSize(imagePositions); const horizontalLineContentHeight = Math.max(maxGlyphSize, maxImageHeight * layoutTextSizeFactor); const verticalLineContentWidth = Math.max(maxGlyphSize, maxImageWidth * layoutTextSizeFactor); return {verticalLineContentWidth, horizontalLineContentHeight}; } function getVerticalAlignFactor( verticalAlign: VerticalAlign ) { switch (verticalAlign) { case 'top': return 0; case 'center': return 0.5; default: return 1; } } function getRectAndMetrics( glyphPosition: GlyphPosition, glyphMap: { [_: string]: { [_: number]: StyleGlyph; }; }, section: TextSectionOptions, codePoint: number ): GlyphPosition | null { if (glyphPosition && glyphPosition.rect) { return glyphPosition; } const glyphs = glyphMap[section.fontStack]; const glyph = glyphs && glyphs[codePoint]; if (!glyph) return null; const metrics = glyph.metrics; return {rect: null, metrics}; } function isLineVertical( writingMode: WritingMode.horizontal | WritingMode.vertical, allowVerticalPlacement: boolean, codePoint: number ): boolean { return !(writingMode === WritingMode.horizontal || // Don't verticalize glyphs that have no upright orientation if vertical placement is disabled. (!allowVerticalPlacement && !codePointHasUprightVerticalOrientation(codePoint)) || // If vertical placement is enabled, don't verticalize glyphs that // are from complex text layout script, or whitespaces. (allowVerticalPlacement && (charIsWhitespace(codePoint) || charInComplexShapingScript(codePoint)))); } function shapeLines(shaping: Shaping, glyphMap: { [_: string]: { [_: number]: StyleGlyph; }; }, glyphPositions: { [_: string]: { [_: number]: GlyphPosition; }; }, imagePositions: {[_: string]: ImagePosition}, lines: Array<TaggedString>, lineHeight: number, textAnchor: SymbolAnchor, textJustify: TextJustify, writingMode: WritingMode.horizontal | WritingMode.vertical, spacing: number, allowVerticalPlacement: boolean, layoutTextSizeThisZoom: number) { let x = 0; let y = 0; let maxLineLength = 0; let maxLineHeight = 0; const justify = textJustify === 'right' ? 1 : textJustify === 'left' ? 0 : 0.5; const layoutTextSizeFactor = ONE_EM / layoutTextSizeThisZoom; let lineIndex = 0; for (const line of lines) { line.trim(); const lineMaxScale = line.getMaxScale(); const positionedLine = {positionedGlyphs: [], lineOffset: 0}; shaping.positionedLines[lineIndex] = positionedLine; const positionedGlyphs = positionedLine.positionedGlyphs; let imageOffset = 0.0; if (!line.length()) { y += lineHeight; // Still need a line feed after empty line ++lineIndex; continue; } const lineShapingSize = calculateLineContentSize(imagePositions, line, layoutTextSizeFactor); let i = 0; for (const char of line.text) { const section = line.getSection(i); const codePoint = char.codePointAt(0); const vertical = isLineVertical(writingMode, allowVerticalPlacement, codePoint); const positionedGlyph: PositionedGlyph = { glyph: codePoint, imageName: null, x, y: y + SHAPING_DEFAULT_OFFSET, vertical, scale: 1, fontStack: '', sectionIndex: line.getSectionIndex(i), metrics: null, rect: null }; let sectionAttributes: ShapingSectionAttributes; if ('fontStack' in section) { sectionAttributes = shapeTextSection(section, codePoint, vertical, lineShapingSize, glyphMap, glyphPositions); if (!sectionAttributes) continue; positionedGlyph.fontStack = section.fontStack; } else { shaping.iconsInText = true; // If needed, allow to set scale factor for an image using // alias "image-scale" that could be alias for "font-scale" // when FormattedSection is an image section. section.scale *= layoutTextSizeFactor; sectionAttributes = shapeImageSection(section, vertical, lineMaxScale, lineShapingSize, imagePositions); if (!sectionAttributes) continue; imageOffset = Math.max(imageOffset, sectionAttributes.imageOffset); positionedGlyph.imageName = section.imageName; } const {rect, metrics, baselineOffset} = sectionAttributes; positionedGlyph.y += baselineOffset; positionedGlyph.scale = section.scale; positionedGlyph.metrics = metrics; positionedGlyph.rect = rect; positionedGlyphs.push(positionedGlyph); if (!vertical) { x += metrics.advance * section.scale + spacing; } else { shaping.verticalizable = true; const verticalAdvance = 'imageName' in section ? metrics.advance : ONE_EM; x += verticalAdvance * section.scale + spacing; } i++; } // Only justify if we placed at least one glyph if (positionedGlyphs.length !== 0) { const lineLength = x - spacing; maxLineLength = Math.max(lineLength, maxLineLength); justifyLine(positionedGlyphs, 0, positionedGlyphs.length - 1, justify); } x = 0; const maxLineOffset = (lineMaxScale - 1) * ONE_EM; positionedLine.lineOffset = Math.max(imageOffset, maxLineOffset); const currentLineHeight = lineHeight * lineMaxScale + imageOffset; y += currentLineHeight; maxLineHeight = Math.max(currentLineHeight, maxLineHeight); ++lineIndex; } // Calculate the bounding box and justify / align text block. const {horizontalAlign, verticalAlign} = getAnchorAlignment(textAnchor); align(shaping.positionedLines, justify, horizontalAlign, verticalAlign, maxLineLength, maxLineHeight, lineHeight, y, lines.length); // Calculate the bounding box // shaping.top & shaping.left already include text offset (text-radial-offset or text-offset) shaping.top += -verticalAlign * y; shaping.bottom = shaping.top + y; shaping.left += -horizontalAlign * maxLineLength; shaping.right = shaping.left + maxLineLength; } function shapeTextSection( section: TextSectionOptions, codePoint: number, vertical: boolean, lineShapingSize: LineShapingSize, glyphMap: { [_: string]: { [_: number]: StyleGlyph; }; }, glyphPositions: { [_: string]: { [_: number]: GlyphPosition; }; }, ): ShapingSectionAttributes | null { const positions = glyphPositions[section.fontStack]; const glyphPosition = positions && positions[codePoint]; const rectAndMetrics = getRectAndMetrics(glyphPosition, glyphMap, section, codePoint); if (rectAndMetrics === null) return null; let baselineOffset: number; if (vertical) { baselineOffset = lineShapingSize.verticalLineContentWidth - section.scale * ONE_EM; } else { const verticalAlignFactor = getVerticalAlignFactor(section.verticalAlign); baselineOffset = (lineShapingSize.horizontalLineContentHeight - section.scale * ONE_EM) * verticalAlignFactor; } return { rect: rectAndMetrics.rect, metrics: rectAndMetrics.metrics, baselineOffset }; } function shapeImageSection( section: ImageSectionOptions, vertical: boolean, lineMaxScale: number, lineShapingSize: LineShapingSize, imagePositions: {[_: string]: ImagePosition}, ): ShapingSectionAttributes | null { const imagePosition = imagePositions[section.imageName]; if (!imagePosition) return null; const rect = imagePosition.paddedRect; const size = imagePosition.displaySize; const metrics = {width: size[0], height: size[1], left: IMAGE_PADDING, top: -GLYPH_PBF_BORDER, advance: vertical ? size[1] : size[0]}; let baselineOffset: number; if (vertical) { baselineOffset = lineShapingSize.verticalLineContentWidth - size[1] * section.scale; } else { const verticalAlignFactor = getVerticalAlignFactor(section.verticalAlign); baselineOffset = (lineShapingSize.horizontalLineContentHeight - size[1] * section.scale) * verticalAlignFactor; } // Difference between height of an image and one EM at max line scale. // Pushes current line down if an image size is over 1 EM at max line scale. const imageOffset = (vertical ? size[0] : size[1]) * section.scale - ONE_EM * lineMaxScale; return {rect, metrics, baselineOffset, imageOffset}; } // justify right = 1, left = 0, center = 0.5 function justifyLine(positionedGlyphs: Array<PositionedGlyph>, start: number, end: number, justify: 1 | 0 | 0.5) { if (justify === 0) return; const lastPositionedGlyph = positionedGlyphs[end]; const lastAdvance = lastPositionedGlyph.metrics.advance * lastPositionedGlyph.scale; const lineIndent = (positionedGlyphs[end].x + lastAdvance) * justify; for (let j = start; j <= end; j++) { positionedGlyphs[j].x -= lineIndent; } } /** * Aligns the lines based on horizontal and vertical alignment. */ function align(positionedLines: Array<PositionedLine>, justify: number, horizontalAlign: number, verticalAlign: number, maxLineLength: number, maxLineHeight: number, lineHeight: number, blockHeight: number, lineCount: number) { const shiftX = (justify - horizontalAlign) * maxLineLength; let shiftY = 0; if (maxLineHeight !== lineHeight) { shiftY = -blockHeight * verticalAlign - SHAPING_DEFAULT_OFFSET; } else { shiftY = -verticalAlign * lineCount * lineHeight + 0.5 * lineHeight; } for (const line of positionedLines) { for (const positionedGlyph of line.positionedGlyphs) { positionedGlyph.x += shiftX; positionedGlyph.y += shiftY; } } } export type PositionedIcon = { image: ImagePosition; top: number; bottom: number; left: number; right: number; collisionPadding?: [number, number, number, number]; }; function shapeIcon( image: ImagePosition, iconOffset: [number, number], iconAnchor: SymbolAnchor ): PositionedIcon { const {horizontalAlign, verticalAlign} = getAnchorAlignment(iconAnchor); const dx = iconOffset[0]; const dy = iconOffset[1]; const x1 = dx - image.displaySize[0] * horizontalAlign; const x2 = x1 + image.displaySize[0]; const y1 = dy - image.displaySize[1] * verticalAlign; const y2 = y1 + image.displaySize[1]; return {image, top: y1, bottom: y2, left: x1, right: x2}; } export interface Box { x1: number; y1: number; x2: number; y2: number; } /** * Called after a PositionedIcon has already been run through fitIconToText, * but needs further adjustment to apply textFitWidth and textFitHeight. * @param shapedIcon - The icon that will be adjusted. * @returns Extents of the shapedIcon with text fit adjustments if necessary. */ function applyTextFit(shapedIcon: PositionedIcon): Box { // Assume shapedIcon.image is set or this wouldn't be called. // Size of the icon after it was adjusted using stretchX and Y let iconLeft = shapedIcon.left; let iconTop = shapedIcon.top; let iconWidth = shapedIcon.right - iconLeft; let iconHeight = shapedIcon.bottom - iconTop; // Size of the original content area const contentWidth = shapedIcon.image.content[2] - shapedIcon.image.content[0]; const contentHeight = shapedIcon.image.content[3] - shapedIcon.image.content[1]; const textFitWidth = shapedIcon.image.textFitWidth ?? TextFit.stretchOrShrink; const textFitHeight = shapedIcon.image.textFitHeight ?? TextFit.stretchOrShrink; const contentAspectRatio = contentWidth / contentHeight; // Scale to the proportional axis first note that height takes precedence if // both axes are set to proportional. if (textFitHeight === TextFit.proportional) { if ((textFitWidth === TextFit.stretchOnly && iconWidth / iconHeight < contentAspectRatio) || textFitWidth === TextFit.proportional) { // Push the width of the icon back out to match the content aspect ratio const newIconWidth = Math.ceil(iconHeight * contentAspectRatio); iconLeft *= newIconWidth / iconWidth; iconWidth = newIconWidth; } } else if (textFitWidth === TextFit.proportional) { if (textFitHeight === TextFit.stretchOnly && contentAspectRatio !== 0 && iconWidth / iconHeight > contentAspectRatio) { // Push the height of the icon back out to match the content aspect ratio const newIconHeight = Math.ceil(iconWidth / contentAspectRatio); iconTop *= newIconHeight / iconHeight; iconHeight = newIconHeight; } } else { // If neither textFitHeight nor textFitWidth are proportional then // there is no effect since the content rectangle should be precisely // matched to the content } return {x1: iconLeft, y1: iconTop, x2: iconLeft + iconWidth, y2: iconTop + iconHeight}; } function fitIconToText( shapedIcon: PositionedIcon, shapedText: Shaping, textFit: string, padding: [number, number, number, number], iconOffset: [number, number], fontScale: number ): PositionedIcon { const image = shapedIcon.image; let collisionPadding; if (image.content) { const content = image.content; const pixelRatio = image.pixelRatio || 1; collisionPadding = [ content[0] / pixelRatio, content[1] / pixelRatio, image.displaySize[0] - content[2] / pixelRatio, image.displaySize[1] - content[3] / pixelRatio ]; } // We don't respect the icon-anchor, because icon-text-fit is set. Instead, // the icon will be centered on the text, then stretched in the given // dimensions. const textLeft = shapedText.left * fontScale; const textRight = shapedText.right * fontScale; let top, right, bottom, left; if (textFit === 'width' || textFit === 'both') { // Stretched horizontally to the text width left = iconOffset[0] + textLeft - padding[3]; right = iconOffset[0] + textRight + padding[1]; } else { // Centered on the text left = iconOffset[0] + (textLeft + textRight - image.displaySize[0]) / 2; right = left + image.displaySize[0]; } const textTop = shapedText.top * fontScale; const textBottom = shapedText.bottom * fontScale; if (textFit === 'height' || textFit === 'both') { // Stretched vertically to the text height top = iconOffset[1] + textTop - padding[0]; bottom = iconOffset[1] + textBottom + padding[2]; } else { // Centered on the text top = iconOffset[1] + (textTop + textBottom - image.displaySize[1]) / 2; bottom = top + image.displaySize[1]; } return {image, top, right, bottom, left, collisionPadding}; }