UNPKG

maplibre-gl

Version:

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

211 lines (190 loc) 8.43 kB
import bidiFactory, {type BidiCharTypeName} from 'bidi-js'; import {toGraphemes} from '../util/graphemes.ts'; const bidi = bidiFactory(); /** * The characters that steer the bidirectional algorithm without being written. * * They have done their work by the time the text has been reordered, and no font draws them, so * they are dropped rather than passed on to be looked up in a glyph atlas. */ const BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/; /** * The character types rule L1 resets to the paragraph's own direction. * * Trailing spaces belong to the paragraph rather than to the run they follow, so a line of Hebrew * ending in a space does not put that space on the wrong end of the line. */ const NEUTRAL_AT_END_OF_LINE = new Set<BidiCharTypeName>(['WS', 'FSI', 'LRI', 'RLI', 'PDI']); /** The character types rule L1 resets wherever they appear, along with the whitespace before them. */ const ALWAYS_RESET = new Set<BidiCharTypeName>(['S', 'B']); /** One grapheme cluster of the text, with everything the reordering rules need to place it. */ type Cluster = { /** Where the cluster starts, in UTF-16 code units. */ index: number; text: string; level: number; }; /** * Splits a line into grapheme clusters, tagging each with the embedding level of its first character. * * Reordering whole clusters is what rule L3 asks for by another route: a letter and the marks * written on it move as one, so reversing a right-to-left run cannot leave the marks stranded before * their letter, and layout gets back the same units of writing it asked for glyphs for. */ function toClusters(text: string, levels: Uint8Array, start: number, end: number): Cluster[] { const clusters: Cluster[] = []; let index = start; for (const cluster of toGraphemes(text.slice(start, end))) { clusters.push({index, text: cluster, level: levels[index]}); index += cluster.length; } return clusters; } /** * Applies rule L1, which hands whitespace at the end of a line back to the paragraph's direction. * * Without it a line of right-to-left text that ends in a space would be drawn with that space on * its left, where the reader does not expect it. */ function resetTrailingNeutrals(clusters: Cluster[], paragraphLevel: number): void { let trailing = true; for (let i = clusters.length - 1; i >= 0; i--) { const type = bidi.getBidiCharTypeName(clusters[i].text[0]); if (ALWAYS_RESET.has(type)) { clusters[i].level = paragraphLevel; trailing = true; } else if (trailing && NEUTRAL_AT_END_OF_LINE.has(type)) { clusters[i].level = paragraphLevel; } else { trailing = false; } } } /** * Applies rule L2, which turns embedding levels into the order the text is read on screen. * * Each level from the deepest down to the shallowest odd one reverses every run of characters at * that level or deeper, so nesting a quotation in one direction inside a sentence in the other comes * out right however far the nesting goes. */ function reorder(clusters: Cluster[], paragraphLevel: number): Cluster[] { let highest = paragraphLevel; let lowestOdd = Infinity; for (const {level} of clusters) { if (level > highest) highest = level; if ((level | 1) < lowestOdd) lowestOdd = level | 1; } const ordered = clusters.slice(); for (let level = highest; level >= lowestOdd; level--) { for (let start = 0; start < ordered.length; start++) { if (ordered[start].level < level) continue; let end = start; while (end + 1 < ordered.length && ordered[end + 1].level >= level) end++; for (let i = start, j = end; i < j; i++, j--) { [ordered[i], ordered[j]] = [ordered[j], ordered[i]]; } start = end; } } return ordered; } /** * Swaps a bracket for its mirror image where it is read right to left. * * An opening parenthesis in Hebrew text is drawn as the shape that opens in that direction, which is * the one Unicode calls a closing parenthesis. */ function mirror(cluster: Cluster): string { if (cluster.level % 2 === 0) return cluster.text; return bidi.getMirroredCharacter(cluster.text) ?? cluster.text; } /** The paragraph a line falls in, which is what its direction is taken from. */ function paragraphLevelAt(paragraphs: Array<{ start: number; end: number; level: number }>, start: number): number { return paragraphs.find(paragraph => start >= paragraph.start && start <= paragraph.end)?.level ?? 0; } /** A line of a label, put into the order it is read and stripped of the characters nothing draws. */ type ReorderedLine = { text: string; /** The code unit of the original text each code unit of {@link ReorderedLine.text} came from. */ sourceIndices: number[]; }; /** * Puts one line into visual order. * * Reordering happens a grapheme cluster at a time, so a letter and the marks written on it stay * together however the line is rearranged. */ function reorderLine( text: string, levels: Uint8Array, paragraphLevel: number, start: number, end: number ): ReorderedLine { const clusters = toClusters(text, levels, start, end); resetTrailingNeutrals(clusters, paragraphLevel); let reorderedText = ''; const sourceIndices: number[] = []; for (const cluster of reorder(clusters, paragraphLevel)) { if (BIDI_CONTROLS.test(cluster.text)) continue; const mirrored = mirror(cluster); reorderedText += mirrored; sourceIndices.push(...Array(mirrored.length).fill(cluster.index)); } return {text: reorderedText, sourceIndices}; } /** * The `[start, end)` code unit range of each line, given every offset the label is to be broken at. * * The offsets are put in order and duplicates dropped, because a line asked to break where a * paragraph already ends should be one line rather than an empty one and a full one. */ function lineRanges(text: string, breakPoints: number[]): Array<[number, number]> { const inside = breakPoints.filter(point => point > 0 && point < text.length); const bounds = [...new Set([0, ...inside, text.length])].sort((a, b) => a - b); const ranges: Array<[number, number]> = []; for (let i = 0; i + 1 < bounds.length; i++) { ranges.push([bounds[i], bounds[i + 1]]); } return ranges; } /** * Reorders every line of a label, keeping where each code unit came from. * * A label is broken where it was asked to be broken and wherever a paragraph ends. The separators * that end a paragraph are the ones the bidirectional algorithm reads its direction afresh after, * so a line may not run across one however wide it is. */ function processLines(text: string, lineBreakPoints: number[]): ReorderedLine[] { const {levels, paragraphs} = bidi.getEmbeddingLevels(text); const breakPoints = lineBreakPoints.concat(paragraphs.map(paragraph => paragraph.start)); return lineRanges(text, breakPoints).map(([start, end]) => reorderLine(text, levels, paragraphLevelAt(paragraphs, start), start, end)); } /** * Puts text into the order it is read on screen, and breaks it into lines. * * @param text - a whole label in logical order * @param lineBreakPoints - the code unit offsets the label is to be broken at * @returns one string per line, each in visual order */ export function processBidirectionalText(text: string, lineBreakPoints: number[]): string[] { return processLines(text, lineBreakPoints).map(line => line.text); } /** * The same as {@link processBidirectionalText}, carrying each code unit's formatting section with it * so that a label built out of several `format` sections keeps its styling once reordered. * * @param text - a whole label in logical order * @param styleIndices - the section each code unit of `text` belongs to * @param lineBreakPoints - the code unit offsets the label is to be broken at * @returns one `[line, styleIndices]` pair per line, each in visual order */ export function processStyledBidirectionalText( text: string, styleIndices: number[], lineBreakPoints: number[] ): Array<[string, number[]]> { return processLines(text, lineBreakPoints).map(line => [line.text, line.sourceIndices.map(index => styleIndices[index] ?? 0)]); }