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