| /* |
| * Copyright 2025 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.text.vertical |
| |
| import android.graphics.Canvas |
| import android.graphics.Paint |
| import android.graphics.Paint.FontMetrics |
| import android.graphics.Paint.FontMetricsInt |
| import android.text.Spanned |
| import android.text.TextPaint |
| import android.text.style.CharacterStyle |
| import java.util.LinkedList |
| import kotlin.concurrent.getOrSet |
| import kotlin.math.max |
| import kotlin.math.min |
| |
| // Constants for better readability. |
| private const val HORIZONTAL = false |
| private const val VERTICAL = true |
| |
| /** |
| * Creates a new LayoutRun instance. |
| * |
| * @param text The text to be laid out. |
| * @param start The inclusive starting index. |
| * @param end The exclusive ending index. |
| * @param paint The TextPaint object used to measure and draw the text. |
| * @param orientation The resolved orientation mode. |
| */ |
| internal fun createLayoutRun( |
| text: CharSequence, |
| start: Int, |
| end: Int, |
| paint: TextPaint, |
| orientation: ResolvedOrientation, |
| ): LayoutRun = |
| when (orientation) { |
| ResolvedOrientation.Rotate -> RotateLayoutRun(text, start, end, paint) |
| ResolvedOrientation.Upright -> UprightLayoutRun(text, start, end, paint) |
| ResolvedOrientation.TateChuYoko -> TateChuYokoLayoutRun(text, start, end, paint) |
| } |
| |
| /** |
| * Represents a segment of text laid out with specific orientation and styling. This is an internal |
| * class used for managing text layout variations. |
| */ |
| internal sealed class LayoutRun(val text: CharSequence, val start: Int, val end: Int) { |
| /** |
| * Distance from left most position from the baseline in pixels. |
| * |
| * This is usually negative value. |
| * |
| * To get the next drawing horizontal coordinate, add this amount to the baseline. To get the |
| * width of this run, subtract this amount from the [rightSideOffset] value, i.e. width = |
| * right - left. |
| */ |
| abstract val leftSideOffset: Float |
| |
| /** |
| * Distance from right most position from the baseline in pixels. |
| * |
| * This is usually positive value. |
| * |
| * To get baseline of this run, subtract this amount from the drawing offset. To get the width |
| * of this run, subtract [leftSideOffset] from this amount, i.e. width = right - left. |
| */ |
| abstract val rightSideOffset: Float |
| |
| /** |
| * Distance from the top to bottom in pixels. |
| * |
| * This is always positive value. |
| */ |
| abstract val height: Float |
| |
| /** |
| * Distance from the right to left in pixels. |
| * |
| * This is always positive value. |
| */ |
| val width |
| get() = rightSideOffset - leftSideOffset |
| |
| /** |
| * Calculates the character advances and stores them in the `out` array. |
| * |
| * [out] must have at least [end - start] elements. |
| * |
| * @param out The array to store the character advances. |
| * @param paint The paint used for text rendering. |
| */ |
| abstract fun getCharAdvances(out: FloatArray, paint: TextPaint) |
| |
| /** |
| * Draws the laid out text on the canvas. |
| * |
| * @param canvas The canvas to draw on. |
| * @param originX The x-coordinate of the top-right corner of the text on the canvas. |
| * @param originY The y-coordinate of the top-right corner of the text on the canvas. |
| * @param paint The paint used for text rendering. |
| */ |
| abstract fun draw(canvas: Canvas, originX: Float, originY: Float, paint: TextPaint) |
| |
| /** Draws the background rectangle on the Canvas. */ |
| protected fun drawBackground( |
| canvas: Canvas, |
| left: Float, |
| top: Float, |
| width: Float, |
| height: Float, |
| bgColor: Int, |
| ) { |
| if (bgColor == 0) { |
| return |
| } |
| tempPaint { bgPaint -> |
| bgPaint.color = bgColor |
| canvas.drawRect(left, top, left + width, top + height, bgPaint) |
| } |
| } |
| } |
| |
| /** |
| * Represents a "Tate-chu-yoko" (horizontal in vertical) text layout run. |
| * |
| * @param text The text this layout represents. |
| * @param start The starting inclusive index of the text. |
| * @param end The ending exclusive index of the text. |
| * @param paint The paint used for text rendering. |
| */ |
| internal class TateChuYokoLayoutRun(text: CharSequence, start: Int, end: Int, paint: TextPaint) : |
| LayoutRun(text, start, end) { |
| override val height: Float |
| get() = descent - ascent |
| |
| override val leftSideOffset: Float |
| override val rightSideOffset: Float |
| |
| /** The ascent of the tate-chu-yoko text. */ |
| private val ascent: Float |
| |
| /** The descent of the tate-chu-yoko text. */ |
| private val descent: Float |
| |
| /** |
| * The horizontal scaling factor applied to the text. |
| * |
| * If the text is too long to fit in the surrounding width, this factor will be used to shrink |
| * the text to fit in the given width. |
| */ |
| private val scaleX: Float |
| |
| /** The actual width occupied by the text. This is used for centering. */ |
| private val textWidth: Float |
| |
| init { |
| var leftSide = 0f |
| var rightSide = 0f |
| var maxAscent = 0 |
| var maxDescent = 0 |
| |
| val fontMetrics = FontMetricsInt() |
| |
| var textWidth = 0f |
| text.forStyleRuns(start, end, paint, HORIZONTAL) { rStart, rEnd, rPaint, _, _ -> |
| val rCount = rEnd - rStart |
| |
| leftSide = min(leftSide, -rPaint.textSize * 0.5f) |
| rightSide = max(rightSide, rPaint.textSize * 0.5f) |
| |
| rPaint.getFontMetricsInt(text, rStart, rCount, rStart, rCount, false, fontMetrics) |
| maxAscent = min(maxAscent, fontMetrics.ascent) |
| maxDescent = max(maxDescent, fontMetrics.descent) |
| |
| textWidth += rPaint.measureText(text, rStart, rEnd) |
| } |
| |
| val w = rightSide - leftSide |
| var scaleX = 1f |
| // Adjust the width and scaling if necessary to fit the text within the desired bounds. |
| if (textWidth > w) { |
| if (textWidth <= w * 1.1f) { |
| // If the text exceeds the width by up to 10%, expand the width by 10%. |
| leftSide *= 1.1f |
| rightSide *= 1.1f |
| } else { |
| // If the text exceeds the width significantly, shrink the text. |
| leftSide *= 1.1f |
| rightSide *= 1.1f |
| scaleX = 1.1f * w / textWidth |
| textWidth = 1.1f * w |
| } |
| } |
| |
| this.descent = maxDescent.toFloat() |
| this.ascent = maxAscent.toFloat() |
| this.leftSideOffset = leftSide |
| this.rightSideOffset = rightSide |
| this.scaleX = scaleX |
| this.textWidth = textWidth |
| } |
| |
| override fun draw(canvas: Canvas, originX: Float, originY: Float, paint: TextPaint) { |
| var x = originX + leftSideOffset + (width - textWidth) / 2 // centering |
| var y = originY - ascent |
| text.forStyleRuns(start, end, paint, HORIZONTAL) { rStart, rEnd, rPaint, bgColor, fontShear |
| -> |
| withTempScaleX(rPaint, scaleX) { |
| val w = rPaint.measureText(text, rStart, rEnd) |
| val h = descent - ascent |
| |
| // Draw a background rectangle if a background color is specified. |
| drawBackground(canvas, x, y + ascent, w, h, bgColor) |
| |
| if (fontShear == 0f) { |
| canvas.drawText(text, rStart, rEnd, x, y, rPaint) |
| } else { |
| canvas.save() |
| try { |
| canvas.translate(x, y) |
| canvas.skew(-fontShear, 0f) |
| canvas.drawText(text, rStart, rEnd, 0f, 0f, rPaint) |
| } finally { |
| canvas.restore() |
| } |
| } |
| |
| // Advance the draw offset for the next style. |
| x += w |
| } |
| } |
| } |
| |
| override fun getCharAdvances(out: FloatArray, paint: TextPaint) { |
| // The line break won't happen inside Tate-chu-yoko span. |
| out[0] = height |
| if (out.size > 1) { |
| out.fill(0f, 1, out.size) |
| } |
| } |
| } |
| |
| /** |
| * Represents a layout run that rotates the text by 90 degrees. |
| * |
| * @param text The text this layout represents. |
| * @param start The starting inclusive index of the text. |
| * @param end The ending exclusive index of the text. |
| * @param paint The paint used for text rendering. |
| */ |
| internal class RotateLayoutRun(text: CharSequence, start: Int, end: Int, paint: TextPaint) : |
| LayoutRun(text, start, end) { |
| override val height: Float |
| override val leftSideOffset: Float |
| override val rightSideOffset: Float |
| private val ascent: Float |
| private val descent: Float |
| |
| init { |
| var height = 0f |
| var leftSide = 0f |
| var rightSide = 0f |
| var ascent = 0f |
| var descent = 0f |
| |
| val metrics = FontMetrics() |
| text.forStyleRuns(start, end, paint, HORIZONTAL) { rStart, rEnd, rPaint, _, _ -> |
| height += rPaint.measureText(text, rStart, rEnd) |
| leftSide = min(leftSide, -rPaint.textSize * 0.5f) |
| rightSide = max(rightSide, rPaint.textSize * 0.5f) |
| rPaint.getFontMetrics(metrics) |
| ascent = min(ascent, metrics.ascent) |
| descent = max(descent, metrics.descent) |
| } |
| |
| this.leftSideOffset = leftSide |
| this.rightSideOffset = rightSide |
| this.height = height |
| this.ascent = ascent |
| this.descent = descent |
| } |
| |
| override fun draw(canvas: Canvas, originX: Float, originY: Float, paint: TextPaint) { |
| canvas.save() |
| try { |
| // To horizontal centering the rotated string, adjust the baseline offset. |
| canvas.translate(originX + (ascent + descent) * 0.5f, originY) |
| canvas.rotate(90f, 0f, 0f) |
| |
| var x = 0f |
| text.forStyleRuns(start, end, paint, HORIZONTAL) { |
| rStart, |
| rEnd, |
| rPaint, |
| bgColor, |
| fontShear -> |
| val width = rPaint.measureText(text, rStart, rEnd) |
| drawBackground(canvas, x, ascent, width, descent - ascent, bgColor) |
| |
| if (fontShear == 0f) { |
| canvas.drawText(text, rStart, rEnd, x, 0f, rPaint) |
| } else { |
| canvas.save() |
| try { |
| canvas.translate(x, 0f) |
| canvas.skew(-fontShear, 0f) |
| canvas.drawText(text, rStart, rEnd, 0f, 0f, rPaint) |
| } finally { |
| canvas.restore() |
| } |
| } |
| x += width |
| } |
| } finally { |
| canvas.restore() |
| } |
| } |
| |
| override fun getCharAdvances(out: FloatArray, paint: TextPaint) { |
| text.forStyleRuns(start, end, paint, HORIZONTAL) { rStart, rEnd, rPaint, _, _ -> |
| rPaint.getRunCharacterAdvance( |
| text, |
| rStart, |
| rEnd, // target range |
| rStart, |
| rEnd, // context range |
| false, // RTL // TODO: support RTL |
| rEnd, // offset, |
| out, |
| rStart - start, |
| ) |
| } |
| } |
| } |
| |
| /** |
| * Represents an upright text layout run, where text is laid out vertically. |
| * |
| * @param text The text this layout represents. |
| * @param start The starting inclusive index of the text. |
| * @param end The ending exclusive index of the text. |
| * @param paint The paint used for text rendering. |
| */ |
| internal class UprightLayoutRun(text: CharSequence, start: Int, end: Int, paint: TextPaint) : |
| LayoutRun(text, start, end) { |
| |
| override val height: Float |
| override val leftSideOffset: Float |
| override val rightSideOffset: Float |
| |
| init { |
| var height = 0f |
| var left = 0f |
| var right = 0f |
| |
| text.forStyleRuns(start, end, paint, VERTICAL) { rStart, rEnd, rPaint, _, _ -> |
| height += rPaint.measureText(text, rStart, rEnd) |
| left = min(left, -rPaint.textSize * 0.5f) |
| right = max(right, rPaint.textSize * 0.5f) |
| } |
| this.height = height |
| this.leftSideOffset = left |
| this.rightSideOffset = right |
| } |
| |
| override fun draw(canvas: Canvas, originX: Float, originY: Float, paint: TextPaint) { |
| var y = originY |
| text.forStyleRuns(start, end, paint, VERTICAL) { rStart, rEnd, rPaint, bgColor, fontShear -> |
| if (bgColor != 0) { |
| tempPaint { bgWorkPaint -> |
| bgWorkPaint.color = bgColor |
| canvas.drawRect( |
| originX + leftSideOffset, |
| y, |
| originX + rightSideOffset, |
| y + height, |
| bgWorkPaint, |
| ) |
| } |
| } |
| |
| if (fontShear == 0f) { |
| canvas.drawText(text, rStart, rEnd, originX, y, rPaint) |
| } else { |
| canvas.save() |
| try { |
| canvas.translate(originX, y) |
| canvas.skew(0f, -fontShear) |
| canvas.drawText(text, rStart, rEnd, 0f, 0f, rPaint) |
| } finally { |
| canvas.restore() |
| } |
| } |
| y += rPaint.measureText(text, rStart, rEnd) |
| } |
| } |
| |
| override fun getCharAdvances(out: FloatArray, paint: TextPaint) { |
| text.forStyleRuns(start, end, paint, VERTICAL) { rStart, rEnd, rPaint, _, _ -> |
| rPaint.getRunCharacterAdvance( |
| text, |
| rStart, |
| rEnd, // target range |
| rStart, |
| rEnd, // context range |
| false, // RTL |
| rEnd, // offset, |
| out, |
| rStart - start, |
| ) // out array and its index |
| } |
| } |
| } |
| |
| /** |
| * Extension function to iterate over style runs within a CharSequence, applying specific styles and |
| * vertical orientation. |
| * |
| * @param start The inclusive start index of the range to process. |
| * @param end The exclusive end index of the range to process. |
| * @param basePaint The base paint to use for drawing. |
| * @param isVertical If true, sets the vertical text flag; otherwise, clears it. |
| */ |
| private inline fun CharSequence.forStyleRuns( |
| start: Int, |
| end: Int, |
| basePaint: TextPaint, |
| isVertical: Boolean, |
| crossinline block: (Int, Int, Paint, Int, Float) -> Unit, |
| ) { |
| // Easy case: if the text is a non-styled text, just call back entire text with applying |
| // vertical flag. |
| if (this !is Spanned) { |
| applyVerticalFlag(basePaint, isVertical) { |
| block(start, end, it, 0 /* bgColor */, 0f /* fontShear */) |
| } |
| return |
| } |
| |
| tempPaint { workPaint -> |
| var current = start |
| while (current < end) { |
| val rEnd = nextSpanTransition(current, end, CharacterStyle::class.java) |
| val styles = getSpans(current, rEnd, CharacterStyle::class.java) |
| |
| var fontShear = 0f |
| workPaint.set(basePaint) |
| styles.forEach { |
| it.updateDrawState(workPaint) |
| if (it is FontShearSpan) { |
| fontShear = it.fontShear // last wins |
| } |
| } |
| applyVerticalFlag(workPaint, isVertical) { |
| block(current, rEnd, workPaint, workPaint.bgColor, fontShear) |
| } |
| current = rEnd |
| } |
| } |
| } |
| |
| /** |
| * Applies or removes the vertical text flag from the given Paint. |
| * |
| * @param paint The paint to modify. |
| * @param isVertical True to add the flag, false to remove it. |
| * @param block A lambda to execute with the modified paint. |
| */ |
| private inline fun applyVerticalFlag( |
| paint: Paint, |
| isVertical: Boolean, |
| crossinline block: (Paint) -> Unit, |
| ) { |
| val originalFlags = paint.flags |
| paint.flags = |
| if (isVertical) { |
| paint.flags or Paint.VERTICAL_TEXT_FLAG |
| } else { |
| paint.flags and Paint.VERTICAL_TEXT_FLAG.inv() |
| } |
| |
| try { |
| block(paint) |
| } finally { |
| paint.flags = originalFlags |
| } |
| } |
| |
| private val paintPool = ThreadLocal<LinkedList<TextPaint>>() |
| |
| private inline fun tempPaint(crossinline block: (TextPaint) -> Unit) { |
| val pool = paintPool.getOrSet { LinkedList<TextPaint>() } |
| var paint = if (pool.isNotEmpty()) pool.remove() else TextPaint() |
| try { |
| block(paint) |
| } finally { |
| if (pool.size <= 2) { // Pool up to two paints. |
| pool.push(paint) |
| } |
| } |
| } |
| |
| /** Executes a block of code with a temporary scaling X of the text size of a given [TextPaint]. */ |
| private inline fun <T : Paint, R> withTempScaleX( |
| textPaint: T, |
| scaleX: Float, |
| crossinline block: () -> R, |
| ): R { |
| val originalScaleX = textPaint.textScaleX |
| textPaint.textScaleX = scaleX |
| try { |
| return block() |
| } finally { |
| textPaint.textScaleX = originalScaleX |
| } |
| } |