UNPKG

maplibre-gl

Version:

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

797 lines (695 loc) 27.1 kB
import {afterEach, describe, test, expect} from 'vitest'; import {toGraphemes} from '../util/graphemes.ts'; import {type PositionedIcon, type Box, type Shaping, applyTextFit, shapeIcon, fitIconToText, shapeText, WritingMode} from './shaping.ts'; import {ImagePosition} from '../render/image_atlas.ts'; import {type StyleImage, TextFit} from '../style/style_image.ts'; import {Formatted} from '@maplibre/maplibre-gl-style-spec'; import {verticalizedCharacterMap} from '../util/verticalize_punctuation.ts'; import {rtlWorkerPlugin} from '../source/rtl_text_plugin_worker.ts'; import type {StyleGlyph} from '../style/style_glyph.ts'; describe('applyTextFit', () => { describe('applyTextFitHorizontal', () => { // This set of tests against applyTextFit starts with a 100x20 image with a 5,5,95,15 content box // that has been scaled to 4x4... resulting in a 14x14 image. const left = 0; const top = 0; const right = 14; const bottom = 14; const rectangle = {x: 0, y: 0, w: 100, h: 20}; const content: [number, number, number, number] = [5, 5, 95, 15]; test('applyTextFit: not specified', async () => { // No change should happen const styleImage: StyleImage = { pixelRatio: 1, version: 1, sdf: false, content, data: undefined!}; const shapedIcon: PositionedIcon = { left, top, right, bottom, image: new ImagePosition(rectangle, styleImage), }; const result: Box = applyTextFit(shapedIcon); expect(result).toEqual({x1: 0, y1: 0, x2: 14, y2: 14}); }); test('applyTextFit: stretchOrShrink', async () => { // No change should happen const styleImage: StyleImage = { pixelRatio: 1, version: 1, sdf: false, content, textFitWidth: TextFit.stretchOrShrink, textFitHeight: TextFit.stretchOrShrink, data: undefined!}; const shapedIcon: PositionedIcon = { left, top, right, bottom, image: new ImagePosition(rectangle, styleImage), }; const result: Box = applyTextFit(shapedIcon); expect(result).toEqual({x1: 0, y1: 0, x2: 14, y2: 14}); }); test('applyTextFit: stretchOnly, proportional', async () => { // Since textFitWidth is stretchOnly, it should be returned to // the aspect ratio of the content rectangle (9:1) aspect ratio so 126x14. const styleImage: StyleImage = { pixelRatio: 1, version: 1, sdf: false, content, textFitWidth: TextFit.stretchOnly, textFitHeight: TextFit.proportional, data: undefined!}; const shapedIcon: PositionedIcon = { left, top, right, bottom, image: new ImagePosition(rectangle, styleImage), }; const result: Box = applyTextFit(shapedIcon); expect(result).toEqual({x1: 0, y1: 0, x2: 126, y2: 14}); }); }); describe('applyTextFitVertical', () => { // This set of tests against applyTextFit starts with a 20x100 image with a 5,5,15,95 content box // that has been scaled to 4x4... resulting in a 14x14 image. const left = 0; const top = 0; const right = 14; const bottom = 14; const rectangle = {x: 0, y: 0, w: 20, h: 100}; const content: [number, number, number, number] = [5, 5, 15, 95]; test('applyTextFit: proportional, stretchOnly', async () => { // Since the rectangle is wider than tall, when it matches based on width (because that is proportional), // then the height will stretch to match the content so we also get a 14x14 image. const styleImage: StyleImage = { pixelRatio: 1, version: 1, sdf: false, content, textFitWidth: TextFit.proportional, textFitHeight: TextFit.stretchOnly, data: undefined!}; const shapedIcon: PositionedIcon = { left, top, right, bottom, image: new ImagePosition(rectangle, styleImage), }; const result: Box = applyTextFit(shapedIcon); expect(result).toEqual({x1: 0, y1: 0, x2: 14, y2: 126}); }); }); }); describe('shapeIcon', () => { const imagePosition = new ImagePosition({x: 0, y: 0, w: 22, h: 22}, {pixelRatio: 1, version: 1} as StyleImage); const image = Object.freeze({ content: undefined, stretchX: undefined, stretchY: undefined, paddedRect: Object.freeze({x: 0, y: 0, w: 22, h: 22}), pixelRatio: 1, version: 1, needsFirstWebGLRender: false }); test('text-anchor: center', () => { expect(shapeIcon(imagePosition, [0, 0], 'center')).toEqual({ top: -10, bottom: 10, left: -10, right: 10, image }); expect(shapeIcon(imagePosition, [4, 7], 'center')).toEqual({ top: -3, bottom: 17, left: -6, right: 14, image }); }); test('text-anchor: left', () => { expect(shapeIcon(imagePosition, [0, 0], 'left')).toEqual({ top: -10, bottom: 10, left: 0, right: 20, image }); expect(shapeIcon(imagePosition, [4, 7], 'left')).toEqual({ top: -3, bottom: 17, left: 4, right: 24, image }); }); test('text-anchor: bottom-right', () => { expect(shapeIcon(imagePosition, [0, 0], 'bottom-right')).toEqual({ top: -20, bottom: 0, left: -20, right: 0, image }); expect(shapeIcon(imagePosition, [4, 7], 'bottom-right')).toEqual({ top: -13, bottom: 7, left: -16, right: 4, image }); }); }); describe('fitIconToText', () => { const glyphSize = 24; const shapedIcon = Object.freeze({ top: -10, bottom: 10, left: -10, right: 10, collisionPadding: undefined, image: Object.freeze({ pixelRatio: 1, displaySize: [20, 20], paddedRect: Object.freeze({x: 0, y: 0, w: 22, h: 22}) }) }) as PositionedIcon; const shapedText = Object.freeze({ top: -10, bottom: 30, left: -60, right: 20 }) as Shaping; test('icon-text-fit: width', () => { expect( fitIconToText(shapedIcon, shapedText, 'width', [0, 0, 0, 0], [0, 0], 24 / glyphSize) ).toEqual({ image: shapedIcon.image, collisionPadding: undefined, top: 0, right: 20, bottom: 20, left: -60 }); expect( fitIconToText(shapedIcon, shapedText, 'width', [0, 0, 0, 0], [3, 7], 24 / glyphSize) ).toEqual({ image: shapedIcon.image, collisionPadding: undefined, top: 7, right: 23, bottom: 27, left: -57 }); expect( fitIconToText(shapedIcon, shapedText, 'width', [0, 0, 0, 0], [0, 0], 12 / glyphSize) ).toEqual({ image: shapedIcon.image, collisionPadding: undefined, top: -5, right: 10, bottom: 15, left: -30 }); // Ignores padding for top/bottom, since the icon is only stretched to the text's width but not height expect( fitIconToText(shapedIcon, shapedText, 'width', [5, 10, 5, 10], [0, 0], 12 / glyphSize) ).toEqual({ image: shapedIcon.image, collisionPadding: undefined, top: -5, right: 20, bottom: 15, left: -40 }); }); test('icon-text-fit: height', () => { expect( fitIconToText(shapedIcon, shapedText, 'height', [0, 0, 0, 0], [0, 0], 24 / glyphSize) ).toEqual({ image: shapedIcon.image, collisionPadding: undefined, top: -10, right: -10, bottom: 30, left: -30 }); expect( fitIconToText(shapedIcon, shapedText, 'height', [0, 0, 0, 0], [3, 7], 24 / glyphSize) ).toEqual({ image: shapedIcon.image, collisionPadding: undefined, top: -3, right: -7, bottom: 37, left: -27 }); expect( fitIconToText(shapedIcon, shapedText, 'height', [0, 0, 0, 0], [0, 0], 12 / glyphSize) ).toEqual({ image: shapedIcon.image, collisionPadding: undefined, top: -5, right: 0, bottom: 15, left: -20 }); // Ignores padding for left/right, since the icon is only stretched to the text's height but not width expect( fitIconToText(shapedIcon, shapedText, 'height', [5, 10, 5, 10], [0, 0], 12 / glyphSize) ).toEqual({ image: shapedIcon.image, collisionPadding: undefined, top: -10, right: 0, bottom: 20, left: -20 }); }); test('icon-text-fit: both', () => { expect( fitIconToText(shapedIcon, shapedText, 'both', [0, 0, 0, 0], [0, 0], 24 / glyphSize) ).toEqual({ image: shapedIcon.image, collisionPadding: undefined, top: -10, right: 20, bottom: 30, left: -60 }); expect( fitIconToText(shapedIcon, shapedText, 'both', [0, 0, 0, 0], [3, 7], 24 / glyphSize) ).toEqual({ image: shapedIcon.image, collisionPadding: undefined, top: -3, right: 23, bottom: 37, left: -57 }); expect( fitIconToText(shapedIcon, shapedText, 'both', [0, 0, 0, 0], [0, 0], 12 / glyphSize) ).toEqual({ image: shapedIcon.image, collisionPadding: undefined, top: -5, right: 10, bottom: 15, left: -30 }); expect( fitIconToText(shapedIcon, shapedText, 'both', [5, 10, 5, 10], [0, 0], 12 / glyphSize) ).toEqual({ image: shapedIcon.image, collisionPadding: undefined, top: -10, right: 20, bottom: 20, left: -40 }); expect( fitIconToText(shapedIcon, shapedText, 'both', [0, 5, 10, 15], [0, 0], 12 / glyphSize) ).toEqual({ image: shapedIcon.image, collisionPadding: undefined, top: -5, right: 15, bottom: 25, left: -45 }); }); }); describe('shapeText vertical glyph orientation', () => { const fontStack = 'Test'; type ShapeLineLabelOptions = { writingMode?: WritingMode.horizontal | WritingMode.vertical; allowVerticalPlacement?: boolean; }; function createStubGlyph(codePoint: number): StyleGlyph { return { id: codePoint, bitmap: null, metrics: {width: 10, height: 18, left: 1, top: -8, advance: 12} }; } /** Keyed by grapheme cluster, which is what layout looks glyphs up by. */ function createStubGlyphMap(text: string): Record<string, StyleGlyph> { const glyphs: Record<string, StyleGlyph> = {}; const verticalizedChars = Object.entries(verticalizedCharacterMap) .filter(([char]) => text.includes(char)) .map(([, verticalizedChar]) => verticalizedChar); for (const grapheme of [...toGraphemes(text), ...verticalizedChars]) { glyphs[grapheme] = createStubGlyph(grapheme.codePointAt(0)); } return glyphs; } function shapeLineLabel(text: string, {writingMode = WritingMode.vertical, allowVerticalPlacement = false}: ShapeLineLabelOptions = {}): Shaping | false { const glyphs = createStubGlyphMap(text); return shapeText(Formatted.fromString(text), {[fontStack]: glyphs}, {}, {}, fontStack, Infinity, 24, 'center', 'center', 0, [0, 0], writingMode, allowVerticalPlacement, 24, 24); } function getGlyphOrientations(shaping: Shaping | false): Array<[string, string]> { expect(shaping).toBeTruthy(); return (shaping as Shaping).positionedLines.flatMap(line => line.positionedGlyphs.map( (glyph): [string, string] => [glyph.grapheme, glyph.vertical ? 'upright' : 'along-line'])); } test('draws digits between CJK characters upright', () => { // The label reported in https://github.com/maplibre/maplibre-gl-js/issues/5404 const shapedLineLabel = shapeLineLabel('반포대로21길'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['반', 'upright'], ['포', 'upright'], ['대', 'upright'], ['로', 'upright'], ['2', 'upright'], ['1', 'upright'], ['길', 'upright'], ]); }); test('draws a trailing digit run upright', () => { const shapedLineLabel = shapeLineLabel('身什戰33'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['身', 'upright'], ['什', 'upright'], ['戰', 'upright'], ['3', 'upright'], ['3', 'upright'], ]); }); test('draws a digit run upright regardless of its length', () => { const shapedLineLabel = shapeLineLabel('国道1234号'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['国', 'upright'], ['道', 'upright'], ['1', 'upright'], ['2', 'upright'], ['3', 'upright'], ['4', 'upright'], ['号', 'upright'], ]); }); test('draws a whitespace-separated digit upright', () => { const shapedLineLabel = shapeLineLabel('身什戰 1'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['身', 'upright'], ['什', 'upright'], ['戰', 'upright'], [' ', 'along-line'], ['1', 'upright'], ]); }); test('draws a single uppercase letter between CJK characters upright', () => { const shapedLineLabel = shapeLineLabel('国道A号'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['国', 'upright'], ['道', 'upright'], ['A', 'upright'], ['号', 'upright'], ]); }); test('draws a decomposed Latin letter and its combining mark as one glyph, so the mark cannot drift off its base', () => { const shapedLineLabel = shapeLineLabel('国道e\u0301号'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['国', 'upright'], ['道', 'upright'], ['e\u0301', 'along-line'], ['号', 'upright'], ]); }); test('rotates a single lowercase letter mixed with CJK', () => { const shapedLineLabel = shapeLineLabel('国道α号'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['国', 'upright'], ['道', 'upright'], ['α', 'along-line'], ['号', 'upright'], ]); }); test('draws short uppercase codes of non-Latin scripts upright', () => { const shapedLineLabel = shapeLineLabel('国道\u041C1号'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['国', 'upright'], ['道', 'upright'], ['\u041C', 'upright'], // Cyrillic capital М ['1', 'upright'], ['号', 'upright'], ]); }); test('rotates complex-shaping script characters mixed with CJK', () => { const shapedLineLabel = shapeLineLabel('国道ب号'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['国', 'upright'], ['道', 'upright'], ['ب', 'along-line'], ['号', 'upright'], ]); }); test('rotates the prolonged sound mark so it reads as a vertical stroke', () => { const shapedLineLabel = shapeLineLabel('札幌タワー'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['札', 'upright'], ['幌', 'upright'], ['タ', 'upright'], ['ワ', 'upright'], ['ー', 'along-line'], ]); }); test('rotates a wave dash between upright digits', () => { const shapedLineLabel = shapeLineLabel('身什戰1〜2'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['身', 'upright'], ['什', 'upright'], ['戰', 'upright'], ['1', 'upright'], ['〜', 'along-line'], ['2', 'upright'], ]); }); test('rotates lowercase Latin words', () => { const shapedLineLabel = shapeLineLabel('two 身什戰'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['t', 'along-line'], ['w', 'along-line'], ['o', 'along-line'], [' ', 'along-line'], ['身', 'upright'], ['什', 'upright'], ['戰', 'upright'], ]); }); test('draws mixed alphanumeric codes upright', () => { const shapedLineLabel = shapeLineLabel('身什戰A1'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['身', 'upright'], ['什', 'upright'], ['戰', 'upright'], ['A', 'upright'], ['1', 'upright'], ]); }); test('rotates Latin words directly adjoining CJK characters', () => { const shapedLineLabel = shapeLineLabel('銀座Ginza通り'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['銀', 'upright'], ['座', 'upright'], ['G', 'along-line'], ['i', 'along-line'], ['n', 'along-line'], ['z', 'along-line'], ['a', 'along-line'], ['通', 'upright'], ['り', 'upright'], ]); }); test('rotates long uppercase words of dual names', () => { const shapedLineLabel = shapeLineLabel('ヴィラ ISHIKAWA'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['ヴ', 'upright'], ['ィ', 'upright'], ['ラ', 'upright'], [' ', 'along-line'], ['I', 'along-line'], ['S', 'along-line'], ['H', 'along-line'], ['I', 'along-line'], ['K', 'along-line'], ['A', 'along-line'], ['W', 'along-line'], ['A', 'along-line'], ]); }); test('draws short uppercase codes upright', () => { const shapedLineLabel = shapeLineLabel('JR山手線'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['J', 'upright'], ['R', 'upright'], ['山', 'upright'], ['手', 'upright'], ['線', 'upright'], ]); }); test('replaces a symbol between upright digits with its vertical form', () => { const shapedLineLabel = shapeLineLabel('国道1-2号'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['国', 'upright'], ['道', 'upright'], ['1', 'upright'], ['︲', 'upright'], // “-” replaced by U+FE32 presentation form for vertical en dash ['2', 'upright'], ['号', 'upright'], ]); }); test('keeps the horizontal form of symbols in runs that stay rotated', () => { const shapedLineLabel = shapeLineLabel('国道3.5km'); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['国', 'upright'], ['道', 'upright'], ['3', 'along-line'], ['.', 'along-line'], ['5', 'along-line'], ['k', 'along-line'], ['m', 'along-line'], ]); }); test('keeps orientations aligned after a missing glyph', () => { // “w” has no glyph, so it produces no positioned glyph; the characters // after it must still get their own orientation, not their neighbor's. const text = 'what 国21号'; const availableGlyphText = [...text] .filter(char => char !== 'w') .join(''); const glyphs = createStubGlyphMap(availableGlyphText); const shapedLineLabel = shapeText(Formatted.fromString(text), {[fontStack]: glyphs}, {}, {}, fontStack, Infinity, 24, 'center', 'center', 0, [0, 0], WritingMode.vertical, false, 24, 24); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['h', 'along-line'], ['a', 'along-line'], ['t', 'along-line'], [' ', 'along-line'], ['国', 'upright'], ['2', 'upright'], ['1', 'upright'], ['号', 'upright'], ]); }); test('does not mark glyphs upright in horizontal writing mode', () => { const shapedLineLabel = shapeLineLabel('身什戰33', {writingMode: WritingMode.horizontal}); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['身', 'along-line'], ['什', 'along-line'], ['戰', 'along-line'], ['3', 'along-line'], ['3', 'along-line'], ]); }); test('draws every non-whitespace glyph upright when vertical placement is allowed', () => { const shapedLineLabel = shapeLineLabel('two 身什戰', {allowVerticalPlacement: true}); const orientations = getGlyphOrientations(shapedLineLabel); expect(orientations).toEqual([ ['t', 'upright'], ['w', 'upright'], ['o', 'upright'], [' ', 'along-line'], ['身', 'upright'], ['什', 'upright'], ['戰', 'upright'], ]); }); }); describe('shapeText with a right-to-left text plugin', () => { const metrics = {width: 10, height: 10, left: 0, top: -8, advance: 10}; function stubReversingPlugin() { rtlWorkerPlugin.processBidirectionalText = (text: string, lineBreaks: number[]) => { const lines: string[] = []; let start = 0; for (const at of [...lineBreaks, text.length]) { if (at > start) lines.push([...text.slice(start, at)].reverse().join('')); start = at; } return lines; }; rtlWorkerPlugin.processStyledBidirectionalText = null; } afterEach(() => { rtlWorkerPlugin.processBidirectionalText = null; rtlWorkerPlugin.processStyledBidirectionalText = null; }); /** A glyph for every grapheme cluster of `text`, and for every codepoint, as a tile asks for. */ function glyphsFor(text: string): Record<string, Record<string, StyleGlyph>> { const glyphs: Record<string, StyleGlyph> = {}; for (const grapheme of toGraphemes(text)) { glyphs[grapheme] = {id: grapheme.codePointAt(0), metrics} as StyleGlyph; for (const char of grapheme) { glyphs[char] = {id: char.codePointAt(0), metrics} as StyleGlyph; } } return {Test: glyphs}; } function shape(text: string, glyphs = glyphsFor(text)): Shaping | false { return shapeText( Formatted.fromString(text), glyphs, {}, {}, 'Test', Infinity, 24, 'center', 'center', 0, [0, 0], WritingMode.horizontal, false, 24, 24, ); } /** The grapheme cluster each positioned glyph draws, in the order they are drawn. */ function drawn(shaping: Shaping | false): string[] { expect(shaping).toBeTruthy(); return (shaping as Shaping).positionedLines.flatMap( line => line.positionedGlyphs.map(glyph => glyph.grapheme)); } const SHIN_SHEVA_DOT = 'שְׁ'; const DALET_TSERE = 'דֵ'; const RESH = 'ר'; const VAV_HOLAM = 'וֹ'; const TAV = 'ת'; const text = SHIN_SHEVA_DOT + DALET_TSERE + RESH + VAV_HOLAM + TAV; test('reverses the letters but keeps each one with the marks written on it, in writing order', () => { stubReversingPlugin(); expect(drawn(shape(text))).toEqual([TAV, VAV_HOLAM, RESH, DALET_TSERE, SHIN_SHEVA_DOT]); }); test('draws only clusters the tile asked for a glyph for', () => { stubReversingPlugin(); const requested = new Set(Object.keys(glyphsFor(text).Test)); for (const grapheme of drawn(shape(text))) { expect(requested).toContain(grapheme); } }); test('falls back to the same codepoints in the same order when no glyph covers the cluster', () => { stubReversingPlugin(); const codepointsOnly: Record<string, StyleGlyph> = {}; for (const char of text) { codepointsOnly[char] = {id: char.codePointAt(0), metrics} as StyleGlyph; } const asClusters = drawn(shape(text)).join(''); const asCodepoints = drawn(shape(text, {Test: codepointsOnly})); expect(asCodepoints.join('')).toBe(asClusters); expect(asCodepoints).toHaveLength([...text].length); }); });