UNPKG

maplibre-gl

Version:

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

208 lines (182 loc) 8 kB
import {ENCODED_JOINING_TYPES, LIGATURES, PRESENTATION_FORMS} from '../util/unicode_properties.g.ts'; /** * How a character joins to the ones beside it. * * `D` joins on both sides, `R` only to the character before it, `L` only to the one after it, `C` * joins without being written itself, `T` is skipped over entirely, and `U` does not join at all. */ type JoiningType = 'R' | 'L' | 'D' | 'C' | 'U' | 'T'; /** Lam, which is the only letter that has to be written as a ligature with the letter after it. */ const LAM = 0x0644; /** * The blocks the Arabic script is written from, which is what makes shaping worth doing at all. * * Arabic proper, then its supplement and two extensions, then the two blocks of shapes this file * rewrites letters into, so that text already shaped is recognised as needing to be looked at again. */ const ARABIC = /[\u0600-\u06ff\u0750-\u077f\u0870-\u089f\u08a0-\u08ff\ufb50-\ufdff\ufe70-\ufeff]/; /** The shape a letter takes, as an index into its entry in {@link PRESENTATION_FORMS}. */ const enum Form { Isolated = 0, Final = 1, Initial = 2, Medial = 3, } /** One character of the text, with the shape the joining rules gave it. */ type ShapedCharacter = { codePoint: number; type: JoiningType; form: Form | null; }; /** * Unpacks the `start,length` ranges of one joining type into the code points they stand for. * * The ranges are delta-encoded against the end of the range before them, so each is read relative to * where the last one stopped. */ function decodeRanges(encoded: string, type: JoiningType, into: Map<number, JoiningType>): void { let previousEnd = 0; for (const range of encoded.split(';')) { const [delta, length] = range.split(',').map(value => parseInt(value, 36)); const start = previousEnd + delta; for (let codePoint = start; codePoint < start + length; codePoint++) { into.set(codePoint, type); } previousEnd = start + length; } } const joiningTypes = new Map<number, JoiningType>(); for (const [type, encoded] of Object.entries(ENCODED_JOINING_TYPES)) { decodeRanges(encoded, type as JoiningType, joiningTypes); } /** Anything the Unicode database does not give a joining type stands on its own. */ function joiningType(codePoint: number): JoiningType { return joiningTypes.get(codePoint) ?? 'U'; } /** Whether the character before this one reaches forward to touch it. */ function joinsForward(type: JoiningType): boolean { return type === 'D' || type === 'L' || type === 'C'; } /** Whether the character after this one reaches back to touch it. */ function joinsBackward(type: JoiningType): boolean { return type === 'D' || type === 'R' || type === 'C'; } /** * The shape a letter takes given what it can reach on either side. * * A dual-joining letter takes all four shapes, while one that joins on a single side only ever * stands alone or closes a word. A letter that never joins is left as it was written: it has one * shape, so naming it by a presentation form would say nothing the code point does not already say. */ function formFor(type: JoiningType, joinedBefore: boolean, joinedAfter: boolean): Form | null { if (type === 'D') { if (joinedBefore && joinedAfter) return Form.Medial; if (joinedBefore) return Form.Final; if (joinedAfter) return Form.Initial; return Form.Isolated; } if (type === 'R') return joinedBefore ? Form.Final : Form.Isolated; if (type === 'L') return joinedAfter ? Form.Initial : Form.Isolated; return null; } /** Splits text into characters, noting the joining type of each. */ function toCharacters(text: string): ShapedCharacter[] { return [...text].map(character => { const codePoint = character.codePointAt(0); return {codePoint, type: joiningType(codePoint), form: null}; }); } /** * Works out the shape of every letter in the text. * * Transparent characters are looked straight through, so a letter joins to its neighbour across any * vowel points written between them. */ function assignLetterForms(characters: ShapedCharacter[]): void { const visible = characters.filter(character => character.type !== 'T'); for (let i = 0; i < visible.length; i++) { const character = visible[i]; const joinedBefore = i > 0 && joinsForward(visible[i - 1].type); const joinedAfter = i + 1 < visible.length && joinsBackward(visible[i + 1].type); character.form = formFor(character.type, joinedBefore, joinedAfter); } } /** * Gives each vowel point the shape it takes over the letter it is written on. * * A mark sits higher and narrower over a letter that carries on into the next one than it does over * one that ends a word, so it takes its shape from whether its letter joins forwards. */ function assignMarkForms(characters: ShapedCharacter[]): void { let letterJoinsForward = false; for (const character of characters) { if (character.type === 'T') { character.form = letterJoinsForward ? Form.Medial : Form.Isolated; } else { letterJoinsForward = character.form === Form.Initial || character.form === Form.Medial; } } } /** * Replaces each lam followed by an alef with the single character the pair is written as. * * The ligature closes a word when the lam it was made from reached back to the letter before it, and * otherwise stands alone. Vowel points written between the two letters are kept, and follow the * ligature they belong to. */ function applyLigatures(characters: ShapedCharacter[]): ShapedCharacter[] { const result: ShapedCharacter[] = []; for (let i = 0; i < characters.length; i++) { const lam = characters[i]; if (lam.codePoint !== LAM) { result.push(lam); continue; } let next = i + 1; while (next < characters.length && characters[next].type === 'T') next++; const alef = characters[next]; const ligature = alef && LIGATURES[String.fromCodePoint(LAM, alef.codePoint)]; if (!ligature) { result.push(lam); continue; } const joinedBefore = lam.form === Form.Medial || lam.form === Form.Final; result.push({codePoint: ligature[joinedBefore ? 1 : 0], type: 'R', form: Form.Isolated}); result.push(...characters.slice(i + 1, next)); i = next; } return result; } /** * The presentation form of this character, or the character itself where there is none to use. * * Not every shape exists to be asked for: a dammatan is only ever encoded as it looks on its own, * so a dammatan written over a joined letter falls back to that one shape rather than to the * unshaped mark, which is what the letters around it have already been shaped to expect. */ function toPresentationForm(character: ShapedCharacter): number { if (character.form === null) return character.codePoint; const forms = PRESENTATION_FORMS[character.codePoint]; if (!forms) return character.codePoint; return forms[character.form] || forms[Form.Isolated] || character.codePoint; } /** * Rewrites Arabic letters as the shape they take in the word they sit in. * * Arabic is cursive: a letter is written differently depending on whether the letters beside it * reach out to touch it. MapLibre draws one glyph at a time and asks for each by code point, so the * shape has to be chosen here and named by the Presentation Forms-B code point that stands for it. * * Text with no Arabic in it is returned unchanged. */ export function applyArabicShaping(text: string): string { if (!ARABIC.test(text)) { return text; } const characters = toCharacters(text); assignLetterForms(characters); assignMarkForms(characters); return applyLigatures(characters) .map(character => String.fromCodePoint(toPresentationForm(character))) .join(''); }