diff --git a/src/data/bucket/symbol_bucket.js b/src/data/bucket/symbol_bucket.js index 89b3e89da..55929a387 100644 --- a/src/data/bucket/symbol_bucket.js +++ b/src/data/bucket/symbol_bucket.js @@ -1,11 +1,12 @@ import { dist } from '@mapwhit/point-geometry'; import { Formatted } from '@mapwhit/style-expressions'; import { VectorTileFeature } from '@mapwhit/vector-tile'; +import { rtlPlugin } from '../../source/rtl_text_plugin.js'; import EvaluationParameters from '../../style/evaluation_parameters.js'; import mergeLines from '../../symbol/mergelines.js'; import { getSizeData } from '../../symbol/symbol_size.js'; import transformText from '../../symbol/transform_text.js'; -import { allowsVerticalWritingMode } from '../../util/script_detection.js'; +import { allowsVerticalWritingMode, stringContainsRTLText } from '../../util/script_detection.js'; import { verticalizedCharacterMap } from '../../util/verticalize_punctuation.js'; import { CollisionBoxLayoutArray, @@ -44,6 +45,15 @@ export function addDynamicAttributes(dynamicLayoutVertexArray, p, angle) { dynamicLayoutVertexArray.emplaceBack(p.x, p.y, angle); } +function containsRTLText(formattedText) { + for (const section of formattedText.sections) { + if (stringContainsRTLText(section.text)) { + return true; + } + } + return false; +} + /** * Unlike other buckets, which simply implement #addFeature with type-specific * logic for (essentially) triangulating feature geometries, SymbolBucket @@ -86,6 +96,7 @@ export default class SymbolBucket { this.pixelRatio = options.pixelRatio; this.sourceLayerIndex = options.sourceLayerIndex; this.hasPattern = false; + this.hasRTLText = false; this.sortKeyRanges = []; const layer = this.layers[0]; @@ -169,11 +180,16 @@ export default class SymbolBucket { // but plain string token evaluation skips that pathway so do the // conversion here. const resolvedTokens = layer.getValueAndResolveTokens('text-field', feature); - text = transformText( - resolvedTokens instanceof Formatted ? resolvedTokens : Formatted.fromString(resolvedTokens), - layer, - feature - ); + const formattedText = + resolvedTokens instanceof Formatted ? resolvedTokens : Formatted.fromString(resolvedTokens); + // on this instance: if hasRTLText is already true, all future calls to containsRTLText can be skipped. + const bucketHasRTLText = (this.hasRTLText = this.hasRTLText || containsRTLText(formattedText)); + if ( + !bucketHasRTLText || // non-rtl text so can proceed safely + rtlPlugin.isRTLSupported(true) // Use the rtlText plugin to shape text if available or We don't intend to lazy-load the rtl text plugin, so proceed with incorrect shaping + ) { + text = transformText(formattedText, layer, feature); + } } let icon; @@ -244,7 +260,9 @@ export default class SymbolBucket { } isEmpty() { - return this.symbolInstances.length === 0; + // When the bucket encounters only rtl-text but the plugin isnt loaded, no symbol instances will be created. + // In order for the bucket to be serialized, and not discarded as an empty bucket both checks are necessary. + return this.symbolInstances.length === 0 && !this.hasRTLText; } uploadPending() { diff --git a/src/index.js b/src/index.js index 725f4142b..bd773c777 100644 --- a/src/index.js +++ b/src/index.js @@ -5,20 +5,21 @@ import { Point } from '@mapwhit/point-geometry'; import packageJSON from '../package.json' with { type: 'json' }; import { default as LngLat } from './geo/lng_lat.js'; import { default as LngLatBounds } from './geo/lng_lat_bounds.js'; -import { setRTLTextPlugin } from './source/rtl_text_plugin.js'; +import { rtlPluginLoader } from './source/rtl_text_plugin.js'; import { default as Style } from './style/style.js'; import { default as Map } from './ui/map.js'; import config from './util/config.js'; const { version } = packageJSON; -export { version, config, setRTLTextPlugin, Point, LngLat, LngLatBounds, Style, Map, Evented }; +export { version, config, setRTLTextPlugin, getRTLTextPluginStatus, Point, LngLat, LngLatBounds, Style, Map, Evented }; // for commonjs backward compatibility const mapwhit = { version, config, setRTLTextPlugin, + getRTLTextPluginStatus, Point, LngLat, LngLatBounds, @@ -27,24 +28,7 @@ const mapwhit = { Evented }; -const properties = { - workerCount: { - get() { - return config.WORKER_COUNT; - }, - set(count) { - config.WORKER_COUNT = count; - } - }, - workerUrl: { - get() { - return config.WORKER_URL; - }, - set(url) { - config.WORKER_URL = url; - } - } -}; +const properties = {}; Object.defineProperties(mapwhit, properties); Object.defineProperties(config, properties); @@ -64,11 +48,27 @@ export default mapwhit; * * @function setRTLTextPlugin * @param {string} pluginURL URL pointing to the Mapbox RTL text plugin source. - * @param {Function} callback Called with an error argument if there is an error. + * @param {boolean} lazy If set to `true`, mapboxgl will defer loading the plugin until rtl text is encountered, + * rtl text will then be rendered only after the plugin finishes loading. + * @example + * setRTLTextPlugin('https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.3.0/dist/mapbox-gl-rtl-text.js', false); + * @see [Add support for right-to-left scripts](https://maplibre.org/maplibre-gl-js/docs/examples/mapbox-gl-rtl-text/) + */ +function setRTLTextPlugin(pluginURL, lazy) { + return rtlPluginLoader.setRTLTextPlugin(pluginURL, lazy); +} +/** + * Gets the map's [RTL text plugin](https://www.mapbox.com/mapbox-gl-js/plugins/#mapbox-gl-rtl-text) status. + * The status can be `unavailable` (i.e. not requested or removed), `loading`, `loaded` or `error`. + * If the status is `loaded` and the plugin is requested again, an error will be thrown. + * + * @function getRTLTextPluginStatus * @example - * mapboxgl.setRTLTextPlugin('https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.2.0/mapbox-gl-rtl-text.js'); - * @see [Add support for right-to-left scripts](https://www.mapbox.com/mapbox-gl-js/example/mapbox-gl-rtl-text/) + * const pluginStatus = getRTLTextPluginStatus(); */ +function getRTLTextPluginStatus() { + return rtlPluginLoader.getRTLTextPluginStatus(); +} // canary assert: used to confirm that asserts have been removed from production build import assert from 'assert'; diff --git a/src/source/rtl_text_plugin.js b/src/source/rtl_text_plugin.js index 175619dc4..8bef13bd5 100644 --- a/src/source/rtl_text_plugin.js +++ b/src/source/rtl_text_plugin.js @@ -1,82 +1,145 @@ +import { Event, Evented } from '@mapwhit/events'; import dynload from 'dynload'; import browser from '../util/browser.js'; -let pluginRequested = false; -let pluginURL; -let loading = false; +/** + * The possible option of the plugin's status + * + * `unavailable`: Not loaded. + * + * `deferred`: The plugin URL has been specified, but loading has been deferred. + * + * `loading`: request in-flight. + * + * `loaded`: The plugin is now loaded + * + * `error`: The plugin failed to load + */ -let _completionCallback; -const _loadedCallbacks = []; - -const rtlPlugin = { - clearRTLTextPlugin, // exported for testing - loadScript, // exported for testing - registerForPluginAvailability, - setRTLTextPlugin -}; +function RTLPlugin() { + const self = { + isRTLSupported + }; -export function registerForPluginAvailability(callback) { - if (plugin.isLoaded()) { - callback(); - return; + /** + * Checks whether the RTL language support is available. + * If `canChangeShortly` is false, it will only return true if the RTL language + * is properly supported. + * If `canChangeShortly` is true, it will also return true if the RTL language + * is not supported unless it can obtain the RTL text plugin. + * @param {boolean} canChangeShortly + * @returns + */ + function isRTLSupported(canChangeShortly) { + if (rtlPluginLoader.getRTLTextPluginStatus() === 'loaded') { + return true; + } + if (!canChangeShortly) { + return false; + } + rtlPluginLoader.lazyLoad(); + // any transitive state other than 'loading' means we can consider RTL supported as best as possible for now + return rtlPluginLoader.getRTLTextPluginStatus() !== 'loading'; } - _loadedCallbacks.push(callback); - loadRTLTextPlugin(); - return () => _loadedCallbacks.splice(_loadedCallbacks.indexOf(callback), 1); -} -export function clearRTLTextPlugin() { - _loadedCallbacks.length = 0; - pluginRequested = false; - pluginURL = undefined; + return self; } -export function setRTLTextPlugin(url, callback) { - if (pluginRequested) { - throw new Error('setRTLTextPlugin cannot be called multiple times.'); +export const rtlPlugin = RTLPlugin(); + +function RTLPluginLoader() { + let status = 'unavailable'; + let url; + + const self = { + getRTLTextPluginStatus, + setRTLTextPlugin, + lazyLoad, + _clearRTLTextPlugin, + _registerRTLTextPlugin + }; + + /** This one is exposed to outside */ + function getRTLTextPluginStatus() { + return status; + } + + // public for testing + function _clearRTLTextPlugin() { + url = undefined; + status = 'unavailable'; + _setMethods(); } - pluginRequested = true; - pluginURL = browser.resolveURL(url); - _completionCallback = error => { - if (error) { - const msg = `RTL Text Plugin failed to load scripts from ${pluginURL}`; - // Clear loaded state to allow retries - clearRTLTextPlugin(); - if (callback) { - callback(new Error(msg)); + + function setRTLTextPlugin(pluginURL, deferred = false) { + if (url) { + // error + return Promise.reject(new Error('setRTLTextPlugin cannot be called multiple times.')); + } + url = browser.resolveURL(pluginURL); + if (!url) { + return Promise.reject(new Error(`requested url ${pluginURL} is invalid`)); + } + if (status === 'requested') { + return _downloadRTLTextPlugin(); + } + if (status === 'unavailable') { + // from initial state: + if (!deferred) { + return _downloadRTLTextPlugin(); } + status = 'deferred'; } - loading = false; - _completionCallback = undefined; - }; - loadRTLTextPlugin(); -} + } -function loadRTLTextPlugin() { - if (pluginURL && !plugin.isLoaded() && _loadedCallbacks.length > 0 && !loading) { - // needs to be called as exported method for mock testing - loading = rtlPlugin.loadScript(pluginURL).catch(_completionCallback); + async function _downloadRTLTextPlugin() { + status = 'loading'; + try { + await rtlPluginLoader._loadScript({ url }); + } catch { + status = 'error'; + } + rtlPluginLoader.fire(new Event('RTLPluginLoaded')); } -} -function registerRTLTextPlugin(loadedPlugin) { - if (plugin.isLoaded()) { - throw new Error('RTL text plugin already registered.'); + /** Start a lazy loading process of RTL plugin */ + function lazyLoad() { + if (status === 'unavailable') { + status = 'requested'; + return; + } + if (status === 'deferred') { + return _downloadRTLTextPlugin(); + } } - plugin['applyArabicShaping'] = loadedPlugin.applyArabicShaping; - plugin['processBidirectionalText'] = loadedPlugin.processBidirectionalText; - plugin['processStyledBidirectionalText'] = loadedPlugin.processStyledBidirectionalText; - if (_completionCallback) { - _completionCallback(); + function _setMethods(rtlTextPlugin) { + if (!rtlTextPlugin) { + // for testing only + rtlPlugin.processStyledBidirectionalText = null; + rtlPlugin.processBidirectionalText = null; + rtlPlugin.applyArabicShaping = null; + return; + } + rtlPlugin.applyArabicShaping = rtlTextPlugin.applyArabicShaping; + rtlPlugin.processBidirectionalText = rtlTextPlugin.processBidirectionalText; + rtlPlugin.processStyledBidirectionalText = rtlTextPlugin.processStyledBidirectionalText; } - _loadedCallbacks.forEach(callback => callback()); - _loadedCallbacks.length = 0; -} -globalThis.registerRTLTextPlugin ??= registerRTLTextPlugin; + // This is invoked by the RTL text plugin when the download has finished, and the code has been parsed. + function _registerRTLTextPlugin(rtlTextPlugin) { + if (rtlPlugin.isRTLSupported()) { + throw new Error('RTL text plugin already registered.'); + } + status = 'loaded'; + _setMethods(rtlTextPlugin); + } + + return self; +} -function loadScript(url) { +// public for testing +function _loadScript({ url }) { const { promise, resolve, reject } = Promise.withResolvers(); const s = dynload(url); s.onload = () => resolve(); @@ -84,11 +147,15 @@ function loadScript(url) { return promise; } -export const plugin = (rtlPlugin.plugin = { - applyArabicShaping: null, - processBidirectionalText: null, - processStyledBidirectionalText: null, - isLoaded: () => plugin.applyArabicShaping != null -}); +const { getRTLTextPluginStatus, setRTLTextPlugin, lazyLoad, _clearRTLTextPlugin, _registerRTLTextPlugin } = + RTLPluginLoader(); + +globalThis.registerRTLTextPlugin ??= _registerRTLTextPlugin; -export default rtlPlugin; +export const rtlPluginLoader = Object.assign(new Evented(), { + getRTLTextPluginStatus, + setRTLTextPlugin, + lazyLoad, + _clearRTLTextPlugin, + _loadScript +}); diff --git a/src/source/worker_tile.js b/src/source/worker_tile.js index 3b2193a20..5f1bfe11a 100644 --- a/src/source/worker_tile.js +++ b/src/source/worker_tile.js @@ -148,6 +148,8 @@ async function finalizeBuckets(params, options, resources) { } async function makeAtlasses({ glyphDependencies, patternDependencies, iconDependencies }, resources) { + // options.glyphDependencies looks like: {"SomeFontName":{"10":true,"32":true}} + // this line makes an object like: {"SomeFontName":[10,32]} const stacks = mapObject(glyphDependencies, glyphs => Object.keys(glyphs).map(Number)); const icons = Object.keys(iconDependencies); const patterns = Object.keys(patternDependencies); diff --git a/src/style/evaluation_parameters.js b/src/style/evaluation_parameters.js index 03b2529ad..d6b49a1f7 100644 --- a/src/style/evaluation_parameters.js +++ b/src/style/evaluation_parameters.js @@ -1,4 +1,4 @@ -import { plugin as rtlTextPlugin } from '../source/rtl_text_plugin.js'; +import { rtlPlugin } from '../source/rtl_text_plugin.js'; import { isStringInSupportedScript } from '../util/script_detection.js'; import ZoomHistory from './zoom_history.js'; @@ -20,7 +20,6 @@ export default class EvaluationParameters { this.transition = {}; } } - crossFadingFactor() { if (this.fadeDuration === 0) { return 1; @@ -40,5 +39,5 @@ export default class EvaluationParameters { } function isSupportedScript(str) { - return isStringInSupportedScript(str, rtlTextPlugin.isLoaded()); + return isStringInSupportedScript(str, rtlPlugin.isRTLSupported()); } diff --git a/src/style/style.js b/src/style/style.js index e5099854a..b419b583d 100644 --- a/src/style/style.js +++ b/src/style/style.js @@ -5,7 +5,7 @@ import ImageManager from '../render/image_manager.js'; import LineAtlas from '../render/line_atlas.js'; import { queryRenderedFeatures, queryRenderedSymbols, querySourceFeatures } from '../source/query_features.js'; import { resources } from '../source/resources/index.js'; -import plugin from '../source/rtl_text_plugin.js'; +import { rtlPluginLoader } from '../source/rtl_text_plugin.js'; import { getType as getSourceType, setType as setSourceType } from '../source/source.js'; import SourceCache from '../source/source_cache.js'; import CrossTileSymbolIndex from '../symbol/cross_tile_symbol_index.js'; @@ -42,6 +42,7 @@ class Style extends Evented { }); #layerIndex = new StyleLayerIndex(); #opsQueue = []; + #rtlPluginLoadedHandler; constructor(map, options = {}) { super(); @@ -63,9 +64,8 @@ class Style extends Evented { this._updatedLayers = new Map(); this._removedLayers = new Map(); this._resetUpdates(); - - this._rtlTextPluginCallbackUnregister = plugin.registerForPluginAvailability(this._reloadSources.bind(this)); - + this.#rtlPluginLoadedHandler = this.#rtlPluginLoaded.bind(this); + rtlPluginLoader.on('RTLPluginLoaded', this.#rtlPluginLoadedHandler); this.on('data', event => { if (event.dataType !== 'source' || event.sourceDataType !== 'metadata') { return; @@ -89,6 +89,18 @@ class Style extends Evented { }); } + #rtlPluginLoaded() { + for (const sourceCache of Object.values(this._sources)) { + const { type } = sourceCache.getSource(); + if (type === 'vector' || type === 'geojson') { + // Non-vector sources don't have any symbols buckets to reload when the RTL text plugin loads + // They also load more quickly, so they're more likely to have already displaying tiles + // that would be unnecessarily booted by the plugin load event + sourceCache.reload(); // Should be a no-op if the plugin loads before any tiles load + } + } + } + setGlobalStateProperty(name, value) { if (!this._loaded) { this.#opsQueue.push(() => this.setGlobalStateProperty(name, value)); @@ -1043,7 +1055,7 @@ class Style extends Evented { } _remove() { - this._rtlTextPluginCallbackUnregister?.(); + rtlPluginLoader.off('RTLPluginLoaded', this.#rtlPluginLoadedHandler); for (const id in this._sources) { this._sources[id].clearTiles(); } @@ -1064,12 +1076,6 @@ class Style extends Evented { } } - _reloadSources() { - for (const sourceCache of Object.values(this._sources)) { - sourceCache.reload(); // Should be a no-op if called before any tiles load - } - } - _generateCollisionBoxes() { for (const id in this._sources) { this._reloadSource(id); diff --git a/src/symbol/shaping.js b/src/symbol/shaping.js index b9349875f..b219bf86e 100644 --- a/src/symbol/shaping.js +++ b/src/symbol/shaping.js @@ -1,4 +1,4 @@ -import { plugin as rtlTextPlugin } from '../source/rtl_text_plugin.js'; +import { rtlPlugin } from '../source/rtl_text_plugin.js'; import { charAllowsIdeographicBreaking, charHasUprightVerticalOrientation } from '../util/script_detection.js'; import verticalizePunctuation from '../util/verticalize_punctuation.js'; import ONE_EM from './one_em.js'; @@ -112,7 +112,7 @@ export function shapeText( let lines; - const { processBidirectionalText, processStyledBidirectionalText } = rtlTextPlugin; + const { processBidirectionalText, processStyledBidirectionalText } = rtlPlugin; if (processBidirectionalText && logicalInput.sections.length === 1) { // Bidi doesn't have to be style-aware lines = []; diff --git a/src/symbol/transform_text.js b/src/symbol/transform_text.js index 79261fdaf..5e43c5275 100644 --- a/src/symbol/transform_text.js +++ b/src/symbol/transform_text.js @@ -1,4 +1,4 @@ -import { plugin as rtlTextPlugin } from '../source/rtl_text_plugin.js'; +import { rtlPlugin } from '../source/rtl_text_plugin.js'; function transformText(text, layer, feature) { const transform = layer._layout.get('text-transform').evaluate(feature, {}); @@ -7,9 +7,8 @@ function transformText(text, layer, feature) { } else if (transform === 'lowercase') { text = text.toLocaleLowerCase(); } - - if (rtlTextPlugin.applyArabicShaping) { - text = rtlTextPlugin.applyArabicShaping(text); + if (rtlPlugin.applyArabicShaping) { + text = rtlPlugin.applyArabicShaping(text); } return text; diff --git a/src/util/config.js b/src/util/config.js index 76135d852..eec211e05 100644 --- a/src/util/config.js +++ b/src/util/config.js @@ -1,9 +1,4 @@ import { Evented } from '@mapwhit/events'; -import browser from './browser.js'; - -function getDefaultWorkerCount() { - return Math.max(Math.floor(browser.hardwareConcurrency / 2), 1); -} const config = new Evented(); @@ -18,7 +13,4 @@ config.notify = function () { config.fire('change', config); }; -config.set({ - WORKER_COUNT: getDefaultWorkerCount(), - WORKER_URL: '' -}); +config.set({}); diff --git a/src/util/script_detection.js b/src/util/script_detection.js index da6a7f17b..354d739ea 100644 --- a/src/util/script_detection.js +++ b/src/util/script_detection.js @@ -415,6 +415,15 @@ export function charHasRotatedVerticalOrientation(char) { return !(charHasUprightVerticalOrientation(char) || charHasNeutralVerticalOrientation(char)); } +export function charInRTLScript(char) { + // Main blocks for Hebrew, Arabic, Thaana and other RTL scripts + return ( + (char >= 0x0590 && char <= 0x08ff) || + isChar['Arabic Presentation Forms-A'](char) || + isChar['Arabic Presentation Forms-B'](char) + ); +} + export function charInSupportedScript(char, canRenderRTL) { // This is a rough heuristic: whether we "can render" a script // actually depends on the properties of the font being used @@ -423,13 +432,7 @@ export function charInSupportedScript(char, canRenderRTL) { // Even in Latin script, we "can't render" combinations such as the fi // ligature, but we don't consider that semantically significant. - if ( - !canRenderRTL && - ((char >= 0x0590 && char <= 0x08ff) || - isChar['Arabic Presentation Forms-A'](char) || - isChar['Arabic Presentation Forms-B'](char)) - ) { - // Main blocks for Hebrew, Arabic, Thaana and other RTL scripts + if (!canRenderRTL && charInRTLScript(char)) { return false; } if ( @@ -448,6 +451,15 @@ export function charInSupportedScript(char, canRenderRTL) { return true; } +export function stringContainsRTLText(chars) { + for (const char of chars) { + if (charInRTLScript(char.charCodeAt(0))) { + return true; + } + } + return false; +} + export function isStringInSupportedScript(chars, canRenderRTL) { for (const char of chars) { if (!charInSupportedScript(char.charCodeAt(0), canRenderRTL)) { diff --git a/test/unit/data/symbol_bucket.test.js b/test/unit/data/symbol_bucket.test.js index 9bfcd103a..9a3645904 100644 --- a/test/unit/data/symbol_bucket.test.js +++ b/test/unit/data/symbol_bucket.test.js @@ -5,11 +5,11 @@ import FeatureIndex from '../../../src/data/feature_index.js'; import Transform from '../../../src/geo/transform.js'; import Tile from '../../../src/source/tile.js'; import { OverscaledTileID } from '../../../src/source/tile_id.js'; -import SymbolStyleLayer from '../../../src/style/style_layer/symbol_style_layer.js'; import CrossTileSymbolIndex from '../../../src/symbol/cross_tile_symbol_index.js'; import { Placement } from '../../../src/symbol/placement.js'; import { performSymbolLayout } from '../../../src/symbol/symbol_layout.js'; import glyphs from '../../fixtures/fontstack-glyphs.json' with { type: 'json' }; +import { createSymbolBucket } from '../../util/create_symbol_layer.js'; import { createPopulateOptions, loadVectorTile } from '../../util/tile.js'; const collisionBoxArray = new CollisionBoxArray(); @@ -20,25 +20,6 @@ transform.cameraToCenterDistance = 100; const stacks = { Test: glyphs }; -function createSymbolBucket(globalState) { - const layer = new SymbolStyleLayer( - { - id: 'test', - type: 'symbol', - layout: { 'text-font': ['Test'], 'text-field': 'abcde' } - }, - globalState - ); - layer.recalculate({ zoom: 0, zoomHistory: {} }); - - return new SymbolBucket({ - overscaling: 1, - zoom: 0, - collisionBoxArray, - layers: [layer] - }); -} - test('SymbolBucket', async t => { let features; t.before(() => { @@ -48,8 +29,8 @@ test('SymbolBucket', async t => { }); await t.test('SymbolBucket', t => { - const bucketA = createSymbolBucket(); - const bucketB = createSymbolBucket(); + const bucketA = createSymbolBucket(collisionBoxArray); + const bucketB = createSymbolBucket(collisionBoxArray); const options = createPopulateOptions(); const placement = new Placement(transform, 0, true); const tileID = new OverscaledTileID(0, 0, 0, 0, 0); @@ -101,7 +82,7 @@ test('SymbolBucket', async t => { }); const warn = t.mock.method(console, 'warn'); - const bucket = createSymbolBucket(); + const bucket = createSymbolBucket(collisionBoxArray); bucket.populate(features, createPopulateOptions()); const fakeGlyph = { rect: { w: 10, h: 10 }, metrics: { left: 10, top: 10, advance: 10 } }; @@ -112,4 +93,34 @@ test('SymbolBucket', async t => { t.assert.equal(warn.mock.callCount(), 1); t.assert.match(warn.mock.calls[0].arguments[0], /Too many glyphs being rendered in a tile./); }); + + await t.test('SymbolBucket detects rtl text', t => { + const rtlBucket = createSymbolBucket(collisionBoxArray, 'مرحبا'); + const ltrBucket = createSymbolBucket(collisionBoxArray, 'hello'); + const options = { iconDependencies: {}, glyphDependencies: {} }; + rtlBucket.populate(features, options); + ltrBucket.populate(features, options); + + t.assert.ok(rtlBucket.hasRTLText); + t.assert.ok(!ltrBucket.hasRTLText); + }); + + // Test to prevent symbol bucket with rtl from text being culled by worker serialization. + await t.test('SymbolBucket with rtl text is NOT empty even though no symbol instances are created', t => { + const rtlBucket = createSymbolBucket(collisionBoxArray, 'مرحبا'); + const options = { iconDependencies: {}, glyphDependencies: {} }; + rtlBucket.createArrays(); + rtlBucket.populate(features, options); + + t.assert.ok(!rtlBucket.isEmpty()); + t.assert.equal(rtlBucket.symbolInstances.length, 0); + }); + + await t.test('SymbolBucket detects rtl text mixed with ltr text', t => { + const mixedBucket = createSymbolBucket(collisionBoxArray, 'مرحبا translates to hello'); + const options = { iconDependencies: {}, glyphDependencies: {} }; + mixedBucket.populate(features, options); + + t.assert.ok(mixedBucket.hasRTLText); + }); }); diff --git a/test/unit/mapbox-gl.test.js b/test/unit/mapbox-gl.test.js index e61c4a549..5ebbfa19e 100644 --- a/test/unit/mapbox-gl.test.js +++ b/test/unit/mapbox-gl.test.js @@ -1,12 +1,8 @@ import test from 'node:test'; -import { config, version } from '../../src/index.js'; +import { version } from '../../src/index.js'; test('mapboxgl', async t => { await t.test('version', t => { t.assert.ok(version); }); - - await t.test('workerCount', t => { - t.assert.ok(typeof config.workerCount === 'number'); - }); }); diff --git a/test/unit/source/rtl_text_plugin.test.js b/test/unit/source/rtl_text_plugin.test.js new file mode 100644 index 000000000..88bef50d9 --- /dev/null +++ b/test/unit/source/rtl_text_plugin.test.js @@ -0,0 +1,68 @@ +import test from 'node:test'; +import { rtlPlugin, rtlPluginLoader } from '../../../src/source/rtl_text_plugin.js'; +import _window from '../../util/window.js'; + +test('RTLPlugin', async t => { + let globalWindow; + t.before(() => { + globalWindow = globalThis.window; + globalThis.window = _window; + }); + t.beforeEach(() => { + // This is a static class, so we need to reset the properties before each test + rtlPluginLoader._clearRTLTextPlugin(); + }); + t.afterEach(() => { + t.mock.reset(); + }); + t.after(() => { + globalThis.window = globalWindow; + }); + + await t.test('initial state', t => { + t.assert.ok(!rtlPlugin.isRTLSupported()); + t.assert.ok(rtlPlugin.isRTLSupported(true)); + t.assert.ok(rtlPlugin.applyArabicShaping == null); + t.assert.ok(rtlPlugin.processBidirectionalText == null); + t.assert.ok(rtlPlugin.processStyledBidirectionalText == null); + }); + + await t.test('plugin loaded', t => { + const rtlTextPlugin = { + applyArabicShaping: () => {}, + processBidirectionalText: () => {}, + processStyledBidirectionalText: () => {} + }; + globalThis.registerRTLTextPlugin(rtlTextPlugin); + t.assert.ok(rtlPlugin.isRTLSupported()); + t.assert.ok(rtlPlugin.isRTLSupported(true)); + t.assert.equal(rtlPlugin.applyArabicShaping, rtlTextPlugin.applyArabicShaping); + t.assert.equal(rtlPlugin.processBidirectionalText, rtlTextPlugin.processBidirectionalText); + t.assert.equal(rtlPlugin.processStyledBidirectionalText, rtlTextPlugin.processStyledBidirectionalText); + }); + + await t.test('plugin deferred', async t => { + t.mock.method(rtlPluginLoader, '_loadScript', () => { + globalThis.registerRTLTextPlugin({}); + return Promise.resolve(); + }); + await rtlPluginLoader.setRTLTextPlugin('http://example.com/plugin', true); + t.assert.ok(!rtlPlugin.isRTLSupported()); + t.assert.ok(rtlPlugin.isRTLSupported(true)); + }); + + await t.test('plugin requested', t => { + t.assert.ok(rtlPlugin.isRTLSupported(true)); + t.assert.ok(!rtlPlugin.isRTLSupported()); + t.assert.ok(rtlPlugin.isRTLSupported(true)); + }); + + await t.test('plugin download failed', async t => { + t.mock.method(rtlPluginLoader, '_loadScript', () => Promise.reject()); + try { + await rtlPluginLoader.setRTLTextPlugin('http://example.com/plugin'); + } catch {} + t.assert.ok(!rtlPlugin.isRTLSupported()); + t.assert.ok(rtlPlugin.isRTLSupported(true)); + }); +}); diff --git a/test/unit/source/rtl_text_plugin_loader.test.js b/test/unit/source/rtl_text_plugin_loader.test.js new file mode 100644 index 000000000..d5e876d45 --- /dev/null +++ b/test/unit/source/rtl_text_plugin_loader.test.js @@ -0,0 +1,150 @@ +import test from 'node:test'; +import { rtlPlugin, rtlPluginLoader } from '../../../src/source/rtl_text_plugin.js'; +import browser from '../../../src/util/browser.js'; +import { sleep } from '../../util/util.js'; +import _window from '../../util/window.js'; + +test('RTLPluginLoader', async t => { + let globalWindow; + const url = 'http://example.com/plugin'; + t.before(() => { + globalWindow = globalThis.window; + globalThis.window = _window; + }); + t.beforeEach(() => { + // Reset the singleton instance before each test + rtlPluginLoader._clearRTLTextPlugin(); + }); + t.afterEach(() => { + t.mock.reset(); + }); + t.after(() => { + globalThis.window = globalWindow; + }); + + await t.test('should get the RTL text plugin status', t => { + const status = rtlPluginLoader.getRTLTextPluginStatus(); + t.assert.equal(status, 'unavailable'); + }); + + await t.test('should set the RTL text plugin and download it', async t => { + t.mock.method(rtlPluginLoader, '_loadScript', () => { + globalThis.registerRTLTextPlugin({}); + return Promise.resolve(); + }); + const promise = rtlPluginLoader.setRTLTextPlugin(url); + await sleep(0); + await promise; + t.assert.deepEqual(rtlPluginLoader.getRTLTextPluginStatus(), 'loaded'); + t.assert.equal(rtlPluginLoader._loadScript.mock.callCount(), 1); + }); + + await t.test('should set the RTL text plugin but defer downloading', async t => { + t.mock.method(rtlPluginLoader, '_loadScript'); + await rtlPluginLoader.setRTLTextPlugin(url, true); + t.assert.equal(rtlPluginLoader._loadScript.mock.callCount(), 0); + t.assert.equal(rtlPluginLoader.getRTLTextPluginStatus(), 'deferred'); + }); + + await t.test('should throw if the plugin is already set', async t => { + await rtlPluginLoader.setRTLTextPlugin(url, true); + await t.assert.rejects(rtlPluginLoader.setRTLTextPlugin(url), { + message: 'setRTLTextPlugin cannot be called multiple times.' + }); + }); + + await t.test('should throw if the plugin url is not set', async t => { + t.mock.method(browser, 'resolveURL', () => ''); + await t.assert.rejects(rtlPluginLoader.setRTLTextPlugin(null), { + message: 'requested url null is invalid' + }); + }); + + await t.test('should be in error state if download fails', async t => { + t.mock.method(rtlPluginLoader, '_loadScript', () => Promise.reject()); + await rtlPluginLoader.setRTLTextPlugin(url); + t.assert.equal(rtlPluginLoader.getRTLTextPluginStatus(), 'error'); + }); + + await t.test('should lazy load the plugin if deferred', async t => { + t.mock.method(rtlPluginLoader, '_loadScript', () => { + globalThis.registerRTLTextPlugin({}); + return Promise.resolve(); + }); + await rtlPluginLoader.setRTLTextPlugin(url, true); + t.assert.equal(rtlPluginLoader._loadScript.mock.callCount(), 0); + t.assert.equal(rtlPluginLoader.getRTLTextPluginStatus(), 'deferred'); + const promise = rtlPluginLoader.lazyLoad(); + await sleep(0); + await promise; + t.assert.equal(rtlPluginLoader.getRTLTextPluginStatus(), 'loaded'); + t.assert.equal(rtlPluginLoader._loadScript.mock.callCount(), 1); + }); + + await t.test('should set status to requested if RTL plugin was not set', t => { + rtlPluginLoader.lazyLoad(); + t.assert.equal(rtlPluginLoader.getRTLTextPluginStatus(), 'requested'); + }); + + await t.test('should immediately download if RTL plugin was already requested, ignoring deferred:true', async t => { + t.mock.method(rtlPluginLoader, '_loadScript', () => { + globalThis.registerRTLTextPlugin({}); + return Promise.resolve(); + }); + rtlPluginLoader.lazyLoad(); + t.assert.equal(rtlPluginLoader.getRTLTextPluginStatus(), 'requested'); + await sleep(1); + // notice even when deferred is true, it should download because already requested + await rtlPluginLoader.setRTLTextPlugin(url, true); + t.assert.equal(rtlPluginLoader.getRTLTextPluginStatus(), 'loaded'); + }); + + await t.test('should allow multiple calls to lazyLoad', t => { + rtlPluginLoader.lazyLoad(); + t.assert.equal(rtlPluginLoader.getRTLTextPluginStatus(), 'requested'); + rtlPluginLoader.lazyLoad(); + t.assert.equal(rtlPluginLoader.getRTLTextPluginStatus(), 'requested'); + }); + + await t.test('should be in error state if lazyLoad fails', async t => { + const resultPromise = rtlPluginLoader.setRTLTextPlugin(url, true); + t.assert.equal(await resultPromise, undefined); + t.assert.equal(rtlPluginLoader.getRTLTextPluginStatus(), 'deferred'); + // the next one should fail + t.mock.method(rtlPluginLoader, '_loadScript', () => Promise.reject()); + await rtlPluginLoader.lazyLoad(); + t.assert.equal(rtlPluginLoader.getRTLTextPluginStatus(), 'error'); + }); + + await t.test('should throw if already parsed', t => { + const rtlTextPlugin = { + applyArabicShaping: () => {}, + processBidirectionalText: () => {}, + processStyledBidirectionalText: () => {} + }; + globalThis.registerRTLTextPlugin(rtlTextPlugin); + t.assert.throws(() => globalThis.registerRTLTextPlugin(rtlTextPlugin), { + message: 'RTL text plugin already registered.' + }); + }); + + await t.test('should not change RTL plugin status if already parsed', t => { + const rtlTextPlugin = { + applyArabicShaping: () => {}, + processBidirectionalText: () => {}, + processStyledBidirectionalText: () => {} + }; + globalThis.registerRTLTextPlugin(rtlTextPlugin); + const rtlTextPlugin2 = { + applyArabicShaping: () => {}, + processBidirectionalText: () => {}, + processStyledBidirectionalText: () => {} + }; + try { + globalThis.registerRTLTextPlugin(rtlTextPlugin2); + } catch {} + t.assert.equal(rtlPlugin.applyArabicShaping, rtlTextPlugin.applyArabicShaping); + t.assert.equal(rtlPlugin.processBidirectionalText, rtlTextPlugin.processBidirectionalText); + t.assert.equal(rtlPlugin.processStyledBidirectionalText, rtlTextPlugin.processStyledBidirectionalText); + }); +}); diff --git a/test/unit/source/tile.test.js b/test/unit/source/tile.test.js index efecc55f6..59999cba4 100644 --- a/test/unit/source/tile.test.js +++ b/test/unit/source/tile.test.js @@ -240,6 +240,6 @@ function createVectorData(options) { ); } -function createPainter() { - return { style: {} }; +function createPainter(styleStub = {}) { + return { style: styleStub }; } diff --git a/test/unit/style/style.test.js b/test/unit/style/style.test.js index 6b53a5d11..230d11a5c 100644 --- a/test/unit/style/style.test.js +++ b/test/unit/style/style.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import { Event, Evented } from '@mapwhit/events'; import { Color } from '@mapwhit/style-expressions'; import Transform from '../../../src/geo/transform.js'; -import plugin from '../../../src/source/rtl_text_plugin.js'; +import { rtlPluginLoader } from '../../../src/source/rtl_text_plugin.js'; import SourceCache from '../../../src/source/source_cache.js'; import { OverscaledTileID } from '../../../src/source/tile_id.js'; import Style from '../../../src/style/style.js'; @@ -63,28 +63,35 @@ test('Style', async t => { style._remove(); }); - await t.test('registers plugin listener', t => { - plugin.clearRTLTextPlugin(); - - t.mock.method(plugin, 'registerForPluginAvailability'); - + await t.test('RTL plugin load reloads vector source but not raster source', async t => { style = new Style(new StubMap()); - t.assert.equal(plugin.registerForPluginAvailability.mock.callCount(), 1); - - t.mock.method(plugin, 'loadScript', () => Promise.reject()); - plugin.setRTLTextPlugin('some-bogus-url'); - t.assert.deepEqual(plugin.loadScript.mock.calls[0].arguments[0], 'https://example.org/some-bogus-url'); - }); - - await t.test('loads plugin immediately if already registered', (t, done) => { - plugin.clearRTLTextPlugin(); - t.mock.method(plugin, 'loadScript', () => Promise.reject(true)); - plugin.setRTLTextPlugin('some-bogus-url', error => { - // Getting this error message shows the bogus URL was succesfully passed to the worker state - t.assert.equal(error.message, 'RTL Text Plugin failed to load scripts from https://example.org/some-bogus-url'); - done(); - }); - style = new Style(createStyleJSON()); + style.loadJSON( + createStyleJSON({ + sources: { + raster: { + type: 'raster', + tiles: ['http://tiles.server'] + }, + vector: { + type: 'vector', + tiles: ['http://tiles.server'] + } + }, + layers: [ + { + id: 'raster', + type: 'raster', + source: 'raster' + } + ] + }) + ); + await style.once('style.load'); + t.mock.method(style._sources.raster, 'reload'); + t.mock.method(style._sources.vector, 'reload'); + rtlPluginLoader.fire(new Event('RTLPluginLoaded')); + t.assert.equal(style._sources.raster.reload.mock.callCount(), 0); + t.assert.equal(style._sources.vector.reload.mock.callCount(), 1); }); }); @@ -351,19 +358,14 @@ test('Style', async t => { }); }); - await t.test('deregisters plugin listener', (t, done) => { + await t.test('deregisters plugin listener', async t => { + t.mock.method(rtlPluginLoader, 'off'); const style = new Style(new StubMap()); - t.mock.method(style, '_reloadSources', () => {}); style.loadJSON(createStyleJSON()); - t.mock.method(plugin, 'loadScript', () => {}); - style.on('style.load', () => { - style._remove(); - plugin.setRTLTextPlugin('some-bogus-url'); - t.assert.equal(plugin.loadScript.mock.callCount(), 0); - t.assert.equal(style._reloadSources.mock.callCount(), 0); - done(); - }); + await style.once('style.load'); + style._remove(); + t.assert.equal(rtlPluginLoader.off.mock.callCount(), 1); }); }); diff --git a/test/util/create_symbol_layer.js b/test/util/create_symbol_layer.js new file mode 100644 index 000000000..74f8e9e7d --- /dev/null +++ b/test/util/create_symbol_layer.js @@ -0,0 +1,21 @@ +import SymbolBucket from '../../src/data/bucket/symbol_bucket.js'; +import SymbolStyleLayer from '../../src/style/style_layer/symbol_style_layer.js'; + +export function createSymbolBucket(collisionBoxArray, text = 'abcde', globalState) { + const layer = new SymbolStyleLayer( + { + id: 'test', + type: 'symbol', + layout: { 'text-font': ['Test'], 'text-field': text } + }, + globalState + ); + layer.recalculate({ zoom: 0, zoomHistory: {} }); + + return new SymbolBucket({ + overscaling: 1, + zoom: 0, + collisionBoxArray, + layers: [layer] + }); +} diff --git a/test/util/util.js b/test/util/util.js index b6a2e9efd..82d4f8cd4 100644 --- a/test/util/util.js +++ b/test/util/util.js @@ -75,3 +75,12 @@ export function waitForEvent(evented, eventName, predicate) { evented.on(eventName, listener); }); } + +/** + * This allows test to wait for a certain amount of time before continuing. + * @param milliseconds - the amount of time to wait in milliseconds + * @returns - a promise that resolves after the specified amount of time + */ +export function sleep(milliseconds = 0) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} diff --git a/test/util/web_worker.js b/test/util/web_worker.js deleted file mode 100644 index ad78b5c68..000000000 --- a/test/util/web_worker.js +++ /dev/null @@ -1,57 +0,0 @@ -// The main thread interface. Provided by Worker in a browser environment, -// and MessageBus below in a node environment. - -class MessageBus { - constructor(addListeners, postListeners) { - this.addListeners = addListeners; - this.postListeners = postListeners; - } - - addEventListener(event, callback) { - if (event === 'message') { - this.addListeners.push(callback); - } - } - - removeEventListener(event, callback) { - const i = this.addListeners.indexOf(callback); - if (i >= 0) { - this.addListeners.splice(i, 1); - } - } - - postMessage(data) { - setImmediate(() => { - try { - for (const listener of this.postListeners) { - listener({ data: data, target: this.target }); - } - } catch (e) { - console.error(e); - } - }); - } - - terminate() { - this.addListeners.splice(0, this.addListeners.length); - this.postListeners.splice(0, this.postListeners.length); - } - - importScripts() {} -} - -function WebWorker() { - const parentListeners = []; - const workerListeners = []; - const parentBus = new MessageBus(workerListeners, parentListeners); - const workerBus = new MessageBus(parentListeners, workerListeners); - - parentBus.target = workerBus; - workerBus.target = parentBus; - - new WebWorker.Worker(workerBus); - - return parentBus; -} - -export default WebWorker; diff --git a/test/util/window.js b/test/util/window.js index 7b8181920..c5bfd042e 100644 --- a/test/util/window.js +++ b/test/util/window.js @@ -1,7 +1,6 @@ import canvas from 'canvas'; import gl from 'gl'; import jsdom from 'jsdom'; -import WebWorker from './web_worker.js'; import '../../src/source/rtl_text_plugin.js'; const _window = create(); @@ -61,12 +60,8 @@ function create() { }; window.WebGLFramebuffer ??= Object; - window.Worker ??= WebWorker; - globalThis.document ??= window.document; - window.registerRTLTextPlugin ??= globalThis.registerRTLTextPlugin; - return window; }