Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions src/data/bucket/symbol_bucket.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
46 changes: 23 additions & 23 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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';
Expand Down
199 changes: 133 additions & 66 deletions src/source/rtl_text_plugin.js
Original file line number Diff line number Diff line change
@@ -1,94 +1,161 @@
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();
s.onerror = () => reject(true);
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
});
2 changes: 2 additions & 0 deletions src/source/worker_tile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading