UNPKG

maplibre-gl

Version:

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

881 lines (774 loc) 32.5 kB
import { codePointHasUprightVerticalOrientation } from '../util/unicode_properties.g.ts'; import { charIsWhitespace, charInComplexShapingScript, charInRTLScript, stringContainsRTLText } from '../util/script_detection.ts'; import {rtlWorkerPlugin} from '../source/rtl_text_plugin_worker.ts'; import {processBidirectionalText, processStyledBidirectionalText} from './bidi.ts'; import {isCluster} from '../util/graphemes.ts'; import {verticalizedCharacterMap} from '../util/verticalize_punctuation.ts'; import ONE_EM from './one_em.ts'; import {TaggedString, type SectionOptions, type TextSectionOptions, type ImageSectionOptions} from './tagged_string.ts'; import type {StyleGlyph, GlyphMetrics} from '../style/style_glyph.ts'; import {GLYPH_PBF_BORDER} from '../style/parse_glyph_pbf.ts'; import {TextFit} from '../style/style_image.ts'; import type {ImagePosition} from '../render/image_atlas.ts'; import {IMAGE_PADDING} from '../render/image_atlas.ts'; import type {Rect, GlyphPosition} from '../render/glyph_atlas.ts'; 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 = { /** * The first codepoint of {@link grapheme}. Kept because it has always been here; it cannot name * a cluster of several codepoints, so prefer `grapheme`. */ glyph: number; /** * What this glyph draws: a grapheme cluster, which is usually one character but is a letter with * its marks where the two are written as one shape. */ grapheme: string; imageName: string | null; x: number; y: number; vertical: boolean; scale: number; fontStack: string; sectionIndex: number; metrics: GlyphMetrics; rect: Rect | null; }; export type PositionedLine = { positionedGlyphs: PositionedGlyph[]; lineOffset: number; }; // A collection of positioned glyphs and some metadata export type Shaping = { positionedLines: 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: 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: number[]): 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; } /** A character that is written on another one rather than beside it. */ const COMBINING_MARK = /^\p{gc=M}$/u; /** * Puts the marks written on a letter back after the letter, and returns the new order as indices * into `chars`. * * This is rule L3 of the Unicode Bidirectional Algorithm. Reversing a right-to-left run leaves each * mark before its letter, and next to the letter before that -- so undoing it is what lets a letter * and its marks be one grapheme cluster again. * * Only marks followed by a right-to-left letter move; in a left-to-right run they already follow * their base. They come back in writing order, `m2 m1 base` to `base m1 m2`, since any other order * is a different cluster from the one the tile asked for a glyph for. */ function combiningMarksAfterTheirBase(chars: string[]): number[] { const order: number[] = []; let i = 0; while (i < chars.length) { if (!COMBINING_MARK.test(chars[i])) { order.push(i); i++; continue; } let end = i; while (end < chars.length && COMBINING_MARK.test(chars[end])) end++; const base = chars[end]; if (base !== undefined && charInRTLScript(base.codePointAt(0))) { order.push(end); for (let mark = end - 1; mark >= i; mark--) order.push(mark); i = end + 1; } else { for (let mark = i; mark < end; mark++) order.push(mark); i = end; } } return order; } /** * Spreads each cluster's style section across the code units it takes, which is what a text plugin * counts in. */ function sectionForEachCodeUnit(input: TaggedString): number[] { const sectionIndex: number[] = []; let i = 0; for (const grapheme of input.graphemes()) { sectionIndex.push(...Array(grapheme.length).fill(input.sectionIndex[i])); i++; } return sectionIndex; } /** * Puts a label that reads in both directions into the order it is drawn, and breaks it into lines. * * The bidirectional algorithm works in code units rather than in the graphemes the rest of layout * counts in, so the line breaks are converted on the way in and the sections on the way out. A * plugin registered through the deprecated {@link setRTLTextPlugin} is used in place of the built-in * implementation, so that a map relying on one keeps the behaviour it had. */ function bidiLines(logicalInput: TaggedString, lineBreaks: number[]): TaggedString[] { const codeUnitBreaks = lineBreaks.map(index => logicalInput.toCodeUnitIndex(index)); const plugin = rtlWorkerPlugin.isParsed() ? rtlWorkerPlugin : null; if (logicalInput.sections.length === 1) { if (plugin) { return plugin.processBidirectionalText(logicalInput.toString(), codeUnitBreaks) .map(line => taggedLineFromPlugin(line, logicalInput.sections, [...line].map(() => 0))); } return processBidirectionalText(logicalInput.toString(), codeUnitBreaks) .map(line => taggedLine([...line], logicalInput.sections, [...line].map(() => 0))); } const sectionForCodeUnit = sectionForEachCodeUnit(logicalInput); if (plugin) { return plugin.processStyledBidirectionalText(logicalInput.text, sectionForCodeUnit, codeUnitBreaks) .map(([line, sections]) => taggedLineFromPlugin(line, logicalInput.sections, sections)); } return processStyledBidirectionalText(logicalInput.text, sectionForCodeUnit, codeUnitBreaks) .map(([line, sections]) => { const chars = [...line]; return taggedLine(chars, logicalInput.sections, codeUnitOfEachCharacter(chars).map(at => sections[at] ?? 0)); }); } /** * Builds a line out of what a text plugin returned: the text in reading order, and the section of * each code unit. A cluster belongs to the section its first character does. */ /** * Builds a line of a label from text that is already in the order it is drawn. * * The sections arrive one per code unit, as the bidirectional algorithm counts, and are handed on * one per grapheme cluster, as the rest of layout counts. */ function taggedLine(chars: string[], sections: SectionOptions[], sectionOfChar: number[]): TaggedString { const tagged = new TaggedString(chars.join(''), sections, []); let at = 0; for (const grapheme of tagged.graphemes()) { tagged.sectionIndex.push(sectionOfChar[at] ?? 0); at += [...grapheme].length; } return tagged; } /** The code unit each character of a line starts at. */ function codeUnitOfEachCharacter(chars: string[]): number[] { const offsets: number[] = []; let codeUnit = 0; for (const char of chars) { offsets.push(codeUnit); codeUnit += char.length; } return offsets; } /** * Builds a line from what a plugin registered through the deprecated `setRTLTextPlugin` returned. * * A plugin reorders one code point at a time, as ICU does, which leaves the marks of a right-to-left * run before the letter they are written on, so rule L3 has to be applied here. The built-in * implementation reorders whole grapheme clusters and needs none of this. */ function taggedLineFromPlugin( line: string, sections: SectionOptions[], sectionForCodeUnit: number[] ): TaggedString { const chars = [...line]; const codeUnitOf = codeUnitOfEachCharacter(chars); const order = combiningMarksAfterTheirBase(chars); return taggedLine( order.map(index => chars[index]), sections, order.map(index => sectionForCodeUnit[codeUnitOf[index]] ?? 0)); } function shapeText( text: Formatted, glyphMap: Record<string, Record<string, StyleGlyph>>, glyphPositions: Record<string, Record<string, GlyphPosition>>, imagePositions: Record<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(); } const lineBreaks = logicalInput.determineLineBreaks(spacing, maxWidth, glyphMap, imagePositions, layoutTextSize); const lines = stringContainsRTLText(logicalInput.text) ? bidiLines(logicalInput, lineBreaks) : 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): {horizontalAlign: number; verticalAlign: number} { 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: Record<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: Record<string, Record<string, StyleGlyph>>, section: TextSectionOptions, key: string ): GlyphPosition | null { if (glyphPosition?.rect) { return glyphPosition; } const glyphs = glyphMap[section.fontStack]; const glyph = glyphs?.[key]; 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)))); } /** Returns whether the codepoint is a decimal digit of any script (`\p{Nd}`). */ function charIsDecimalDigit(codePoint: number): boolean { return /\p{Nd}/u.test(String.fromCodePoint(codePoint)); } /** Returns whether the codepoint is an uppercase letter of any script (`\p{Lu}`). */ function charIsUppercaseLetter(codePoint: number): boolean { return /\p{Lu}/u.test(String.fromCodePoint(codePoint)); } /** Returns whether the codepoint is a punctuation or symbol character (`\p{P}` or `\p{S}`). */ function charIsSymbolOrPunctuation(codePoint: number): boolean { return /[\p{P}\p{S}]/u.test(String.fromCodePoint(codePoint)); } /** * Uppercase runs longer than this are words (e.g. “ISHIKAWA” in a dual name), * which read better lying along the line; shorter runs are codes (“JR”, “A1”). */ const MAX_UPRIGHT_LETTER_RUN = 3; /** * Returns whether a run qualifies for upright treatment: numbers of any length, * optionally combined with symbols (“21”, “1-2”), and codes of up to * {@link MAX_UPRIGHT_LETTER_RUN} uppercase letters and digits (“JR”, “A1”). */ function runIsUpright(run: number[]): boolean { const isNumber = run.some(charIsDecimalDigit) && run.every(codePoint => charIsDecimalDigit(codePoint) || charIsSymbolOrPunctuation(codePoint)); const isShortUppercaseCode = run.length <= MAX_UPRIGHT_LETTER_RUN && run.every(codePoint => charIsUppercaseLetter(codePoint) || charIsDecimalDigit(codePoint)); return isNumber || isShortUppercaseCode; } /** * Returns whether a letter or digit in a qualifying run is drawn upright. * Punctuation and symbols are handled separately by * {@link verticalizeSurroundedPunctuation}; complex-shaping scripts keep * following the line. */ function charIsUprightInRun(codePoint: number): boolean { return (charIsDecimalDigit(codePoint) || charIsUppercaseLetter(codePoint)) && !charInComplexShapingScript(codePoint); } /** * Replaces punctuation surrounded by upright characters with its vertical * presentation form (“-” in “1-2” becomes “︲”) and marks it upright. * `verticalizePunctuation` cannot do this earlier: it doesn't know which * characters {@link determineLineVerticals} draws upright. * * Returns whether anything in `chars` was replaced. */ function verticalizeSurroundedPunctuation(chars: string[], verticals: boolean[]): boolean { let replaced = false; for (let i = 0; i < chars.length; i++) { if (verticals[i]) continue; const verticalizedChar = verticalizedCharacterMap[chars[i]]; if (!verticalizedChar) continue; if ((i === 0 || verticals[i - 1]) && (i === chars.length - 1 || verticals[i + 1])) { chars[i] = verticalizedChar; verticals[i] = true; replaced = true; } } return replaced; } /** * Returns, for each grapheme cluster of a vertically laid out line label, whether * its glyph is drawn upright rather than lying along the line, and updates * `line` with vertical presentation forms of punctuation. * * A run passed to {@link runIsUpright} is a maximal sequence of non-upright * characters that are neither whitespace nor inline images. * * Counted in clusters, to line up with `getSection` and the layout loop. A cluster's orientation is * that of the character it starts with. */ function determineLineVerticals(line: TaggedString): boolean[] { const chars = line.graphemes().slice(); const codePoints = chars.map(char => char.codePointAt(0)); const verticals = codePoints.map(codePointHasUprightVerticalOrientation); const isRunCharacter = (i: number): boolean => !verticals[i] && !charIsWhitespace(codePoints[i]) && !('imageName' in line.getSection(i)); for (let start = 0; start < codePoints.length; start++) { if (!isRunCharacter(start)) continue; let end = start; while (end + 1 < codePoints.length && isRunCharacter(end + 1)) end++; if (runIsUpright(codePoints.slice(start, end + 1))) { for (let i = start; i <= end; i++) { verticals[i] = charIsUprightInRun(codePoints[i]); } } start = end; } if (verticalizeSurroundedPunctuation(chars, verticals)) { line.text = chars.join(''); line._graphemes = null; } return verticals; } /** * Places every glyph of every line, filling in `shaping`. * * A cluster is drawn as one shape where a font file covers it, and a codepoint at a time where none * does -- which is what a style declaring no `font-faces` keeps doing. */ function shapeLines(shaping: Shaping, glyphMap: Record<string, Record<string, StyleGlyph>>, glyphPositions: Record<string, Record<string, GlyphPosition>>, imagePositions: Record<string, ImagePosition>, lines: 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); const lineVerticals = writingMode === WritingMode.vertical && !allowVerticalPlacement ? determineLineVerticals(line) : null; const graphemes = line.graphemes(); for (let i = 0; i < graphemes.length; i++) { const section = line.getSection(i); const grapheme = graphemes[i]; const codePoint = grapheme.codePointAt(0); const vertical = lineVerticals ? lineVerticals[i] : isLineVertical(writingMode, allowVerticalPlacement, codePoint); const keys = 'fontStack' in section && isCluster(grapheme) && !glyphMap[section.fontStack]?.[grapheme] ? [...grapheme] : [grapheme]; for (const key of keys) { const positionedGlyph: PositionedGlyph = { glyph: key.codePointAt(0), grapheme: key, 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, key, 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; } } } // 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, key: string, vertical: boolean, lineShapingSize: LineShapingSize, glyphMap: Record<string, Record<string, StyleGlyph>>, glyphPositions: Record<string, Record<string, GlyphPosition>>, ): ShapingSectionAttributes | null { const positions = glyphPositions[section.fontStack]; const glyphPosition = positions?.[key]; const rectAndMetrics = getRectAndMetrics(glyphPosition, glyphMap, section, key); 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: Record<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: 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: 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 type 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}; }