1 /* 2 * Copyright 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 @file:Suppress("PrimitiveInCollection") 18 19 package androidx.compose.ui.text.android 20 21 import android.text.Layout 22 import android.text.TextUtils 23 import androidx.annotation.IntRange 24 import java.text.Bidi 25 26 private const val LINE_FEED = '\n' 27 28 /** 29 * Provide utilities for Layout class 30 * 31 * This class is not thread-safe. Do not share an instance with multiple threads. 32 */ 33 internal class LayoutHelper(val layout: Layout) { 34 35 private val paragraphEnds: List<Int> 36 37 // Stores the list of Bidi object for each paragraph. This could be null if Bidi is not 38 // necessary, i.e. single direction text. Do not use this directly. Use analyzeBidi function 39 // instead. 40 private val paragraphBidi: MutableList<Bidi?> 41 42 // Stores true if the each paragraph already has bidi analyze result. Do not use this 43 // directly. Use analyzeBidi function instead. 44 private val bidiProcessedParagraphs: BooleanArray 45 46 // Temporary buffer for bidi processing. 47 private var tmpBuffer: CharArray? = null 48 49 init { 50 var paragraphEnd = 0 51 val lineFeeds = mutableListOf<Int>() 52 do { 53 paragraphEnd = layout.text.indexOf(char = LINE_FEED, startIndex = paragraphEnd) 54 if (paragraphEnd < 0) { 55 // No more LINE_FEED char found. Use the end of the text as the paragraph end. 56 paragraphEnd = layout.text.length 57 } else { 58 // increment since end offset is exclusive. 59 paragraphEnd++ 60 } 61 lineFeeds.add(paragraphEnd) 62 } while (paragraphEnd < layout.text.length) 63 paragraphEnds = lineFeeds <lambda>null64 paragraphBidi = MutableList(paragraphEnds.size) { null } 65 bidiProcessedParagraphs = BooleanArray(paragraphEnds.size) 66 } 67 68 /** 69 * Analyze the BiDi runs for the paragraphs and returns result object. 70 * 71 * Layout#isRtlCharAt or Layout#getLineDirection is not useful for determining preceding or 72 * following run in visual order. We need to analyze by ourselves. 73 * 74 * This may return null if the Bidi process is not necessary, i.e. there is only single bidi 75 * run. 76 * 77 * @param paragraphIndex a paragraph index 78 */ analyzeBidinull79 fun analyzeBidi(paragraphIndex: Int): Bidi? { 80 // If we already analyzed target paragraph, just return the result. 81 if (bidiProcessedParagraphs[paragraphIndex]) { 82 return paragraphBidi[paragraphIndex] 83 } 84 85 val paragraphStart = if (paragraphIndex == 0) 0 else paragraphEnds[paragraphIndex - 1] 86 val paragraphEnd = paragraphEnds[paragraphIndex] 87 val paragraphLength = paragraphEnd - paragraphStart 88 89 // We allocate the character buffer for saving memories. The internal implementation 90 // anyway allocate character buffer even if we pass text through 91 // AttributedCharacterIterator. Also there is no way of passing 92 // Bidi.DIRECTION_DEFAULT_RIGHT_TO_LEFT via AttributedCharacterIterator. 93 // 94 // We also cannot always reuse this buffer since the internal Bidi object keeps this 95 // reference and use it for creating lineBidi. We may be able to share buffer by avoiding 96 // using lineBidi but this is internal implementation details, so share memory as 97 // much as possible and allocate new buffer if we need Bidi object. 98 var buffer = tmpBuffer 99 buffer = 100 if (buffer == null || buffer.size < paragraphLength) { 101 CharArray(paragraphLength) 102 } else { 103 buffer 104 } 105 TextUtils.getChars(layout.text, paragraphStart, paragraphEnd, buffer, 0) 106 107 val result = 108 if (Bidi.requiresBidi(buffer, 0, paragraphLength)) { 109 val flag = 110 if (isRtlParagraph(paragraphIndex)) { 111 Bidi.DIRECTION_RIGHT_TO_LEFT 112 } else { 113 Bidi.DIRECTION_LEFT_TO_RIGHT 114 } 115 val bidi = Bidi(buffer, 0, null, 0, paragraphLength, flag) 116 117 if (bidi.runCount == 1) { 118 // This corresponds to the all text is Right-to-Left case. We don't need to keep 119 // Bidi object 120 null 121 } else { 122 bidi 123 } 124 } else { 125 null 126 } 127 128 paragraphBidi[paragraphIndex] = result 129 bidiProcessedParagraphs[paragraphIndex] = true 130 131 tmpBuffer = 132 if (result != null) { 133 // The ownership of buffer is now passed to Bidi object. 134 // Release tmpBuffer if we didn't allocated in this time. 135 if (buffer === tmpBuffer) null else tmpBuffer 136 } else { 137 // We might allocate larger buffer in this time. Update tmpBuffer with latest one. 138 // (the latest buffer may be same as tmpBuffer) 139 buffer 140 } 141 return result 142 } 143 144 /** Retrieve the number of the paragraph in this layout. */ 145 val paragraphCount = paragraphEnds.size 146 147 /** 148 * Returns the zero based paragraph number at the offset. 149 * 150 * The paragraphs are divided by line feed character (U+000A) and line feed character is 151 * included in the preceding paragraph, i.e. if the offset points the line feed character, this 152 * function returns preceding paragraph index. 153 * 154 * @param offset a character offset in the text 155 * @return the paragraph number 156 */ getParagraphForOffsetnull157 fun getParagraphForOffset(@IntRange(from = 0) offset: Int, upstream: Boolean = false): Int { 158 val paragraphIndex = 159 paragraphEnds.binarySearch(offset).let { if (it < 0) -(it + 1) else it + 1 } 160 161 if (upstream && paragraphIndex > 0 && offset == paragraphEnds[paragraphIndex - 1]) { 162 return paragraphIndex - 1 163 } 164 165 return paragraphIndex 166 } 167 168 /** 169 * Returns the inclusive paragraph starting offset of the given paragraph index. 170 * 171 * @param paragraphIndex a paragraph index. 172 * @return an inclusive start character offset of the given paragraph. 173 */ getParagraphStartnull174 fun getParagraphStart(@IntRange(from = 0) paragraphIndex: Int) = 175 if (paragraphIndex == 0) 0 else paragraphEnds[paragraphIndex - 1] 176 177 /** 178 * Returns the exclusive paragraph end offset of the given paragraph index. 179 * 180 * @param paragraphIndex a paragraph index. 181 * @return an exclusive end character offset of the given paragraph. 182 */ 183 fun getParagraphEnd(@IntRange(from = 0) paragraphIndex: Int) = paragraphEnds[paragraphIndex] 184 185 /** 186 * Returns true if the resolved paragraph direction is RTL, otherwise return false. 187 * 188 * @param paragraphIndex a paragraph index 189 * @return true if the paragraph is RTL, otherwise false 190 */ 191 fun isRtlParagraph(@IntRange(from = 0) paragraphIndex: Int): Boolean { 192 val lineNumber = layout.getLineForOffset(getParagraphStart(paragraphIndex)) 193 return layout.getParagraphDirection(lineNumber) == Layout.DIR_RIGHT_TO_LEFT 194 } 195 196 /** 197 * Returns horizontal offset from the drawing origin 198 * 199 * This is the location where a new character would be inserted. If offset points the line 200 * broken offset, this return the insertion offset of preceding line if upstream is true. 201 * Otherwise returns the following line's insertion offset. 202 * 203 * In case of Bi-Directional text, the offset may points graphically different location. Here 204 * primary means that the inserting character's direction will be resolved to the same direction 205 * to the paragraph direction. For example, set usePrimaryHorizontal to true if you want to get 206 * LTR character insertion position for the LTR paragraph, or if you want to get RTL character 207 * insertion position for the RTL paragraph. Set usePrimaryDirection to false if you want to get 208 * RTL character insertion position for the LTR paragraph, or if you want to get LTR character 209 * insertion position for the RTL paragraph. 210 * 211 * @param offset an offset to be insert a character 212 * @param usePrimaryDirection no effect if the given offset does not point the directionally 213 * transition point. If offset points the directional transition point and this argument is 214 * true, treat the given offset as the offset of the Bidi run that has the same direction to 215 * the paragraph direction. Otherwise treat the given offset as the offset of the Bidi run 216 * that has the different direction to the paragraph direction. 217 * @param upstream if offset points the line broken offset, use upstream offset if true, 218 * otherwise false. 219 * @return the horizontal offset from the drawing origin. 220 */ getHorizontalPositionnull221 fun getHorizontalPosition(offset: Int, usePrimaryDirection: Boolean, upstream: Boolean): Float { 222 // Android already calculates downstream 223 if (!upstream) { 224 return getDownstreamHorizontal(offset, usePrimaryDirection) 225 } 226 227 val lineNo = layout.getLineForOffset(offset, upstream) 228 val lineStart = layout.getLineStart(lineNo) 229 val lineEnd = layout.getLineEnd(lineNo) 230 231 // Early exit if the offset points not an edge of line. There is no difference between 232 // downstream and upstream horizontals. This includes out-of-range request 233 if (offset != lineStart && offset != lineEnd) { 234 return getDownstreamHorizontal(offset, usePrimaryDirection) 235 } 236 237 // Similarly, even if the offset points the edge of the line start and line end, we can 238 // use downstream result. 239 if (offset == 0 || offset == layout.text.length) { 240 return getDownstreamHorizontal(offset, usePrimaryDirection) 241 } 242 243 val paraNo = getParagraphForOffset(offset, upstream) 244 val isParaRtl = isRtlParagraph(paraNo) 245 246 // Use line visible end for creating bidi object since invisible whitespaces should not be 247 // considered for location retrieval. 248 val lineVisibleEnd = lineEndToVisibleEnd(lineEnd, lineStart) 249 val paragraphStart = getParagraphStart(paraNo) 250 val bidiStart = lineStart - paragraphStart 251 val bidiEnd = lineVisibleEnd - paragraphStart 252 val lineBidi = analyzeBidi(paraNo)?.createLineBidi(bidiStart, bidiEnd) 253 if (lineBidi == null || lineBidi.runCount == 1) { // easy case. All directions are the same 254 val runDirection = layout.isRtlCharAt(lineStart) 255 val isStartLeft = 256 if (usePrimaryDirection || isParaRtl == runDirection) { 257 !isParaRtl 258 } else { 259 isParaRtl 260 } 261 val isOffsetLeft = if (offset == lineStart) isStartLeft else !isStartLeft 262 return if (isOffsetLeft) layout.getLineLeft(lineNo) else layout.getLineRight(lineNo) 263 } 264 265 // Somehow need to find the character's position without using getPrimaryHorizontal. 266 val runs = 267 Array(lineBidi.runCount) { 268 // We may be able to reduce this Bidi Run allocation by using run indices 269 // but unfortunately, Bidi#reorderVisually only accepts array of Object. So auto 270 // boxing happens anyway. Also, looks like Bidi#getRunStart and Bidi#getRunLimit 271 // does non-trivial amount of work. So we save the result into BidiRun. 272 BidiRun( 273 start = lineStart + lineBidi.getRunStart(it), 274 end = lineStart + lineBidi.getRunLimit(it), 275 isRtl = lineBidi.getRunLevel(it) % 2 == 1 276 ) 277 } 278 val levels = ByteArray(lineBidi.runCount) { lineBidi.getRunLevel(it).toByte() } 279 Bidi.reorderVisually(levels, 0, runs, 0, runs.size) 280 281 if (offset == lineStart) { 282 // find the visual position of the last character 283 val index = runs.indexOfFirst { it.start == offset } 284 val run = runs[index] 285 // True if the requesting end offset is left edge of the run. 286 val isLeftRequested = 287 if (usePrimaryDirection || isParaRtl == run.isRtl) { 288 !isParaRtl 289 } else { 290 isParaRtl 291 } 292 293 if (index == 0 && isLeftRequested) { 294 // Requesting most left run's left offset, just use line left. 295 return layout.getLineLeft(lineNo) 296 } else if (index == runs.lastIndex && !isLeftRequested) { 297 // Requesting most right run's right offset, just use line right. 298 return layout.getLineRight(lineNo) 299 } else if (isLeftRequested) { 300 // Reaching here means the run is LTR, since RTL run cannot be start from the 301 // middle of the text in RTL context. 302 // This is LTR run, so left position of this run is the same to left 303 // RTL run's right (i.e. start) position. 304 return layout.getPrimaryHorizontal(runs[index - 1].start) 305 } else { 306 // Reaching here means the run is RTL, since LTR run cannot be start from the 307 // middle of the text in LTR context. 308 // This is RTL run, so right position of this run is the same to right 309 // LTR run's left (i.e. start) position. 310 return layout.getPrimaryHorizontal(runs[index + 1].start) 311 } 312 } else { 313 // Bidi runs are created between lineStart and lineVisibleEnd 314 // If the requested offset is a white space at the end of the line, it would be 315 // out of bounds for the runs in this Bidi. We are adjusting the requested offset 316 // to the visible end of line. 317 val lineEndAdjustedOffset = 318 if (offset > lineVisibleEnd) { 319 lineEndToVisibleEnd(offset, lineStart) 320 } else { 321 offset 322 } 323 // find the visual position of the last character 324 val index = runs.indexOfFirst { it.end == lineEndAdjustedOffset } 325 val run = runs[index] 326 // True if the requesting end offset is left edge of the run. 327 val isLeftRequested = 328 if (usePrimaryDirection || isParaRtl == run.isRtl) { 329 isParaRtl 330 } else { 331 !isParaRtl 332 } 333 if (index == 0 && isLeftRequested) { 334 // Requesting most left run's left offset, just use line left. 335 return layout.getLineLeft(lineNo) 336 } else if (index == runs.lastIndex && !isLeftRequested) { 337 // Requesting most right run's right offset, just use line right. 338 return layout.getLineRight(lineNo) 339 } else if (isLeftRequested) { 340 // Reaching here means the run is RTL, since LTR run cannot be broken from the 341 // middle of the text in LTR context. 342 // This is RTL run, so left position of this run is the same to left 343 // LTR run's right (i.e. end) position. 344 return layout.getPrimaryHorizontal(runs[index - 1].end) 345 } else { // !isEndLeft 346 // Reaching here means the run is LTR, since RTL run cannot be broken from the 347 // middle of the text in RTL context. 348 // This is LTR run, so right position of this run is the same to right 349 // RTL run's left (i.e. end) position. 350 return layout.getPrimaryHorizontal(runs[index + 1].end) 351 } 352 } 353 } 354 355 /** 356 * Return the text offset after the last visible character on the specified line. For example 357 * whitespaces are not counted as visible characters. 358 */ getLineVisibleEndnull359 fun getLineVisibleEnd(lineIndex: Int): Int { 360 return lineEndToVisibleEnd(layout.getLineEnd(lineIndex), layout.getLineStart(lineIndex)) 361 } 362 getDownstreamHorizontalnull363 private fun getDownstreamHorizontal(offset: Int, primary: Boolean): Float { 364 val lineNo = layout.getLineForOffset(offset) 365 val lineEnd = layout.getLineEnd(lineNo) 366 367 // [android.text.Layout#getHorizontal] has a bug that causes a crash if requested offset 368 // is in an ellipsized region and comes after a line feed character. We coerce at most to 369 // lineEnd of the line this offset belongs to. getLineEnd respects line feed characters. 370 // Any ellipsized character should already return the visible end value, which they do until 371 // a line feed character. We can safely assume rest of the characters can also return the 372 // same result as the reported line end. 373 val targetOffset = offset.coerceAtMost(lineEnd) 374 375 return if (primary) { 376 layout.getPrimaryHorizontal(targetOffset) 377 } else { 378 layout.getSecondaryHorizontal(targetOffset) 379 } 380 } 381 382 internal data class BidiRun(val start: Int, val end: Int, val isRtl: Boolean) 383 384 /** 385 * Convert line end offset to the offset that is the last visible character. Last visible 386 * character on this line cannot be before line start. 387 */ lineEndToVisibleEndnull388 private fun lineEndToVisibleEnd(lineEnd: Int, lineStart: Int): Int { 389 var visibleEnd = lineEnd 390 while (visibleEnd > lineStart) { 391 if (isLineEndSpace(layout.text[visibleEnd - 1 /* visibleEnd is exclusive */])) { 392 visibleEnd-- 393 } else { 394 break 395 } 396 } 397 return visibleEnd 398 } 399 getLineBidiRunsnull400 internal fun getLineBidiRuns(lineIndex: Int): Array<BidiRun> { 401 val lineStart = layout.getLineStart(lineIndex) 402 val lineEnd = layout.getLineEnd(lineIndex) 403 404 val paragraphIndex = getParagraphForOffset(lineStart) 405 val paragraphStart = getParagraphStart(paragraphIndex) 406 407 val bidiStart = lineStart - paragraphStart 408 val bidiEnd = lineEnd - paragraphStart 409 val lineBidi = 410 analyzeBidi(paragraphIndex)?.createLineBidi(bidiStart, bidiEnd) 411 ?: return arrayOf(BidiRun(lineStart, lineEnd, layout.isRtlCharAt(lineStart))) 412 413 return Array(lineBidi.runCount) { 414 BidiRun( 415 start = lineStart + lineBidi.getRunStart(it), 416 end = lineStart + lineBidi.getRunLimit(it), 417 isRtl = lineBidi.getRunLevel(it) % 2 == 1 418 ) 419 } 420 } 421 422 // The spaces that will not be rendered if they are placed at the line end. In most case, it is 423 // whitespace or line feed character, hence checking linearly should be enough. 424 @Suppress("ConvertTwoComparisonsToRangeCheck") isLineEndSpacenull425 fun isLineEndSpace(c: Char) = 426 c == ' ' || 427 c == '\n' || 428 c == '\u1680' || 429 (c >= '\u2000' && c <= '\u200A' && c != '\u2007') || 430 c == '\u205F' || 431 c == '\u3000' 432 } 433