Initial implementation of VerticalTextLayout
The VerticalTextLayout provides basic text layout of vertical system.
The initial implementation provides following features:
- Simple line break (greedy algorithm).
- Text layout of multiple orientation text including override portion
of text with spans including TextCombineUpright (known as
Tate-Chu-Yoko in Japanese).
- Simple Ruby implemenataion. Currently only ruby text size and
orientation mode can be configurable.
Bug: 393984479
Test: TreeHugger
Relnote: "Initial implementation of vertical text. Please see API Doc of
VerticalTextLayout for more details."
Change-Id: I12535e76d3d7f893cdc49675c0c93b35867f34bc
diff --git a/settings.gradle b/settings.gradle
index 7bfddaf..8a5bac9 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1048,6 +1048,7 @@
includeProject(":test:uiautomator:uiautomator-lint", [BuildType.MAIN])
includeProject(":test:uiautomator:integration-tests:testapp", [BuildType.MAIN])
includeProject(":text:text-vertical", [BuildType.MAIN])
+includeProject(":text:text-vertical-testapp", "text/text-vertical/testapp", [BuildType.MAIN])
includeProject(":tracing:tracing")
includeProject(":tracing:tracing-benchmark", "tracing/benchmark", [BuildType.MAIN, BuildType.COMPOSE])
includeProject(":tracing:tracing-ktx")
diff --git a/text/text-vertical/api/current.txt b/text/text-vertical/api/current.txt
index e6f50d0..caed866 100644
--- a/text/text-vertical/api/current.txt
+++ b/text/text-vertical/api/current.txt
@@ -1 +1,70 @@
// Signature format: 4.0
+package androidx.text.vertical {
+
+ public final class RubySpan {
+ method public int getOrientation();
+ method public CharSequence getText();
+ method public float getTextScale();
+ property public int orientation;
+ property public CharSequence text;
+ property public float textScale;
+ }
+
+ public static final class RubySpan.Builder {
+ ctor public RubySpan.Builder(CharSequence text);
+ method public androidx.text.vertical.RubySpan build();
+ method public androidx.text.vertical.RubySpan.Builder setOrientation(int orientation);
+ method public androidx.text.vertical.RubySpan.Builder setTextScale(float textScale);
+ }
+
+ public final class TextOrientation {
+ property public static int MIXED;
+ property public static int SIDEWAYS;
+ property public static int UPRIGHT;
+ field public static final androidx.text.vertical.TextOrientation INSTANCE;
+ field public static final int MIXED = 0; // 0x0
+ field public static final int SIDEWAYS = 2; // 0x2
+ field public static final int UPRIGHT = 1; // 0x1
+ }
+
+ public sealed interface TextOrientationSpan {
+ }
+
+ public static final class TextOrientationSpan.Sideways implements androidx.text.vertical.TextOrientationSpan {
+ ctor public TextOrientationSpan.Sideways();
+ }
+
+ public static final class TextOrientationSpan.TextCombineUpright implements androidx.text.vertical.TextOrientationSpan {
+ ctor public TextOrientationSpan.TextCombineUpright();
+ }
+
+ public static final class TextOrientationSpan.Upright implements androidx.text.vertical.TextOrientationSpan {
+ ctor public TextOrientationSpan.Upright();
+ }
+
+ public final class VerticalTextLayout {
+ method public void draw(android.graphics.Canvas canvas, @Px float x, @Px float y);
+ method public int getEnd();
+ method public float getHeight();
+ method public int getOrientation();
+ method public android.text.TextPaint getPaint();
+ method public int getStart();
+ method public CharSequence getText();
+ method @Px public float getWidth();
+ property public int end;
+ property @Px public float height;
+ property public int orientation;
+ property public android.text.TextPaint paint;
+ property public int start;
+ property public CharSequence text;
+ property @Px public float width;
+ }
+
+ public static final class VerticalTextLayout.Builder {
+ ctor public VerticalTextLayout.Builder(CharSequence text, int start, int end, android.text.TextPaint paint, @Px float height);
+ method public androidx.text.vertical.VerticalTextLayout build();
+ method public androidx.text.vertical.VerticalTextLayout.Builder setOrientation(int orientation);
+ }
+
+}
+
diff --git a/text/text-vertical/api/restricted_current.txt b/text/text-vertical/api/restricted_current.txt
index e6f50d0..caed866 100644
--- a/text/text-vertical/api/restricted_current.txt
+++ b/text/text-vertical/api/restricted_current.txt
@@ -1 +1,70 @@
// Signature format: 4.0
+package androidx.text.vertical {
+
+ public final class RubySpan {
+ method public int getOrientation();
+ method public CharSequence getText();
+ method public float getTextScale();
+ property public int orientation;
+ property public CharSequence text;
+ property public float textScale;
+ }
+
+ public static final class RubySpan.Builder {
+ ctor public RubySpan.Builder(CharSequence text);
+ method public androidx.text.vertical.RubySpan build();
+ method public androidx.text.vertical.RubySpan.Builder setOrientation(int orientation);
+ method public androidx.text.vertical.RubySpan.Builder setTextScale(float textScale);
+ }
+
+ public final class TextOrientation {
+ property public static int MIXED;
+ property public static int SIDEWAYS;
+ property public static int UPRIGHT;
+ field public static final androidx.text.vertical.TextOrientation INSTANCE;
+ field public static final int MIXED = 0; // 0x0
+ field public static final int SIDEWAYS = 2; // 0x2
+ field public static final int UPRIGHT = 1; // 0x1
+ }
+
+ public sealed interface TextOrientationSpan {
+ }
+
+ public static final class TextOrientationSpan.Sideways implements androidx.text.vertical.TextOrientationSpan {
+ ctor public TextOrientationSpan.Sideways();
+ }
+
+ public static final class TextOrientationSpan.TextCombineUpright implements androidx.text.vertical.TextOrientationSpan {
+ ctor public TextOrientationSpan.TextCombineUpright();
+ }
+
+ public static final class TextOrientationSpan.Upright implements androidx.text.vertical.TextOrientationSpan {
+ ctor public TextOrientationSpan.Upright();
+ }
+
+ public final class VerticalTextLayout {
+ method public void draw(android.graphics.Canvas canvas, @Px float x, @Px float y);
+ method public int getEnd();
+ method public float getHeight();
+ method public int getOrientation();
+ method public android.text.TextPaint getPaint();
+ method public int getStart();
+ method public CharSequence getText();
+ method @Px public float getWidth();
+ property public int end;
+ property @Px public float height;
+ property public int orientation;
+ property public android.text.TextPaint paint;
+ property public int start;
+ property public CharSequence text;
+ property @Px public float width;
+ }
+
+ public static final class VerticalTextLayout.Builder {
+ ctor public VerticalTextLayout.Builder(CharSequence text, int start, int end, android.text.TextPaint paint, @Px float height);
+ method public androidx.text.vertical.VerticalTextLayout build();
+ method public androidx.text.vertical.VerticalTextLayout.Builder setOrientation(int orientation);
+ }
+
+}
+
diff --git a/text/text-vertical/build.gradle b/text/text-vertical/build.gradle
index f4e4ebe..6cfa0aa 100644
--- a/text/text-vertical/build.gradle
+++ b/text/text-vertical/build.gradle
@@ -31,11 +31,22 @@
dependencies {
api(libs.kotlinStdlib)
- // Add dependencies here
+ api("androidx.annotation:annotation:1.8.1")
+
+ androidTestImplementation(libs.testExtJunit)
+ androidTestImplementation(libs.testCore)
+ androidTestImplementation(libs.testRules)
+ androidTestImplementation(libs.testRunner)
+ androidTestImplementation(libs.truth)
}
android {
namespace = "androidx.text.vertical"
+
+ defaultConfig {
+ minSdk = 36
+ }
+ compileSdk = 36
}
androidx {
diff --git a/text/text-vertical/src/androidTest/java/androidx/text/vertical/LayoutRunTest.kt b/text/text-vertical/src/androidTest/java/androidx/text/vertical/LayoutRunTest.kt
new file mode 100644
index 0000000..56109bf
--- /dev/null
+++ b/text/text-vertical/src/androidTest/java/androidx/text/vertical/LayoutRunTest.kt
@@ -0,0 +1,285 @@
+/*
+ * 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.FontMetricsInt
+import android.text.TextPaint
+import androidx.text.vertical.ResolvedOrientation.Rotate
+import androidx.text.vertical.ResolvedOrientation.TateChuYoko
+import androidx.text.vertical.ResolvedOrientation.Upright
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class TextRunsTest {
+ private val PREFIX = "PREFIX_PREFIX_PREFIX"
+ private val SUFFIX = "SUFFIX_SUFFIX_SUFFIX"
+ private val JAPANESE_TEXT = "あいうえお"
+ private val LATIN_TEXT = "abcde"
+
+ private val TEXT = PREFIX + LATIN_TEXT + JAPANESE_TEXT + SUFFIX
+ private val LATIN_START = PREFIX.length
+ private val LATIN_END = LATIN_START + LATIN_TEXT.length
+ private val JAPANESE_START = LATIN_END
+ private val JAPANESE_END = JAPANESE_START + JAPANESE_TEXT.length
+
+ private val ONE_EM = 10f // make 1em = 10px
+ private val HALF_EM = ONE_EM / 2
+
+ private val PAINT = TextPaint().apply { textSize = ONE_EM }
+
+ private fun getVerticalAdvance(text: String): Float {
+ PAINT.flags = PAINT.flags or Paint.VERTICAL_TEXT_FLAG
+ return PAINT.measureText(text)
+ }
+
+ private fun getHorizontalAdvance(text: String): Float {
+ PAINT.flags = PAINT.flags and Paint.VERTICAL_TEXT_FLAG.inv()
+ return PAINT.measureText(text)
+ }
+
+ private fun getHorizontalLineHeight(text: String): Float {
+ val fm = FontMetricsInt()
+ PAINT.getFontMetricsInt(text, 0, text.length, 0, text.length, false, fm)
+ return (fm.descent - fm.ascent).toFloat()
+ }
+
+ private fun createLayoutRun(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ orientation: ResolvedOrientation
+ ) = createLayoutRun(text, start, end, PAINT, orientation)
+
+ private class MockCanvas(val drawTextCallback: (CharSequence, Int, Int, Paint) -> Unit) :
+ Canvas() {
+ override fun drawText(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ x: Float,
+ y: Float,
+ paint: Paint
+ ) {
+ super.drawText(text, start, end, x, y, paint)
+ drawTextCallback(text, start, end, paint)
+ }
+ }
+
+ @Test
+ fun `Upright SingleStyle Latin`() {
+ createLayoutRun(TEXT, LATIN_START, LATIN_END, Upright).run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_END)
+ assertThat(width).isEqualTo(ONE_EM) // width is 1em.
+ assertThat(height).isEqualTo(getVerticalAdvance(LATIN_TEXT))
+ assertThat(leftSideOffset).isEqualTo(-HALF_EM) // leftSide is half of 1em
+ assertThat(rightSideOffset).isEqualTo(HALF_EM) // rightSide is half of 1em
+
+ draw(
+ MockCanvas { text, start, end, paint ->
+ assertThat(text).isEqualTo(TEXT)
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_END)
+ assertThat(paint.hasVerticalTextFlag()).isTrue()
+ },
+ 0f,
+ 0f,
+ PAINT
+ )
+ }
+ }
+
+ @Test
+ fun `Upright SingleStyle Japanese`() {
+ createLayoutRun(TEXT, JAPANESE_START, JAPANESE_END, Upright).run {
+ assertThat(start).isEqualTo(JAPANESE_START)
+ assertThat(end).isEqualTo(JAPANESE_END)
+ assertThat(width).isEqualTo(ONE_EM) // width is 1em.
+ assertThat(height).isEqualTo(getVerticalAdvance(JAPANESE_TEXT))
+ assertThat(leftSideOffset).isEqualTo(-HALF_EM) // leftSide is half of 1em
+ assertThat(rightSideOffset).isEqualTo(HALF_EM) // rightSide is half of 1em
+
+ draw(
+ MockCanvas { text, start, end, paint ->
+ assertThat(text).isEqualTo(TEXT)
+ assertThat(start).isEqualTo(JAPANESE_START)
+ assertThat(end).isEqualTo(JAPANESE_END)
+ assertThat(paint.hasVerticalTextFlag()).isTrue()
+ },
+ 0f,
+ 0f,
+ PAINT
+ )
+ }
+ }
+
+ @Test
+ fun `Rotate SingleStyle Latin`() {
+ createLayoutRun(TEXT, LATIN_START, LATIN_END, Rotate).run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_END)
+ assertThat(width).isEqualTo(ONE_EM) // width is 1em.
+ assertThat(height).isEqualTo(getHorizontalAdvance(LATIN_TEXT))
+ assertThat(leftSideOffset).isEqualTo(-HALF_EM) // leftSide is half of 1em
+ assertThat(rightSideOffset).isEqualTo(HALF_EM) // rightSide is half of 1em
+
+ draw(
+ MockCanvas { text, start, end, paint ->
+ assertThat(text).isEqualTo(TEXT)
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_END)
+ assertThat(paint.hasVerticalTextFlag()).isFalse()
+ },
+ 0f,
+ 0f,
+ PAINT
+ )
+ }
+ }
+
+ @Test
+ fun `Rotate SingleStyle Japanese`() {
+ createLayoutRun(TEXT, JAPANESE_START, JAPANESE_END, Rotate).run {
+ assertThat(start).isEqualTo(JAPANESE_START)
+ assertThat(end).isEqualTo(JAPANESE_END)
+ assertThat(width).isEqualTo(ONE_EM) // width is 1em.
+ assertThat(height).isEqualTo(getVerticalAdvance(JAPANESE_TEXT))
+ assertThat(leftSideOffset).isEqualTo(-HALF_EM) // leftSide is half of 1em
+ assertThat(rightSideOffset).isEqualTo(HALF_EM) // rightSide is half of 1em
+
+ draw(
+ MockCanvas { text, start, end, paint ->
+ assertThat(text).isEqualTo(TEXT)
+ assertThat(start).isEqualTo(JAPANESE_START)
+ assertThat(end).isEqualTo(JAPANESE_END)
+ assertThat(paint.hasVerticalTextFlag()).isFalse()
+ },
+ 0f,
+ 0f,
+ PAINT
+ )
+ }
+ }
+
+ @Test
+ fun `TateChuYoko SingleStyle Latin Can fit into 1em`() {
+ createLayoutRun(TEXT, LATIN_START, LATIN_START + 1, TateChuYoko).run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_START + 1)
+ assertThat(width).isEqualTo(ONE_EM) // width is 1em.
+ assertThat(height).isEqualTo(getHorizontalLineHeight(LATIN_TEXT))
+ assertThat(leftSideOffset).isEqualTo(-HALF_EM) // leftSide is half of 1em
+ assertThat(rightSideOffset).isEqualTo(HALF_EM) // rightSide is half of 1em
+
+ draw(
+ MockCanvas { text, start, end, paint ->
+ assertThat(text).isEqualTo(TEXT)
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_START + 1)
+ assertThat(paint.hasVerticalTextFlag()).isFalse()
+ assertThat(paint.textScaleX).isEqualTo(1f)
+ },
+ 0f,
+ 0f,
+ PAINT
+ )
+ }
+ }
+
+ @Test
+ fun `TateChuYoko SingleStyle Japanese Can fit into 1em`() {
+ createLayoutRun(TEXT, JAPANESE_START, JAPANESE_START + 1, TateChuYoko).run {
+ assertThat(start).isEqualTo(JAPANESE_START)
+ assertThat(end).isEqualTo(JAPANESE_START + 1)
+ assertThat(width).isEqualTo(ONE_EM) // width is 1em.
+ assertThat(height).isEqualTo(getHorizontalLineHeight(JAPANESE_TEXT))
+ assertThat(leftSideOffset).isEqualTo(-HALF_EM) // leftSide is half of 1em
+ assertThat(rightSideOffset).isEqualTo(HALF_EM) // rightSide is half of 1em
+
+ draw(
+ MockCanvas { text, start, end, paint ->
+ assertThat(text).isEqualTo(TEXT)
+ assertThat(start).isEqualTo(JAPANESE_START)
+ assertThat(end).isEqualTo(JAPANESE_START + 1)
+ assertThat(paint.hasVerticalTextFlag()).isFalse()
+ assertThat(paint.textScaleX).isEqualTo(1f)
+ },
+ 0f,
+ 0f,
+ PAINT
+ )
+ }
+ }
+
+ @Test
+ fun `TateChuYoko SingleStyle Latin Stretch`() {
+ createLayoutRun(TEXT, LATIN_START, LATIN_START + 4, TateChuYoko).run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_START + 4)
+ assertThat(width).isEqualTo(ONE_EM * 1.1f) // allocate 1.1em for stretched text
+ assertThat(height).isEqualTo(getHorizontalLineHeight(LATIN_TEXT))
+ assertThat(leftSideOffset).isEqualTo(-HALF_EM * 1.1f) // leftSide is half of 1.1em
+ assertThat(rightSideOffset).isEqualTo(HALF_EM * 1.1f) // rightSide is half of 1.1em
+
+ draw(
+ MockCanvas { text, start, end, paint ->
+ assertThat(text).isEqualTo(TEXT)
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_START + 4)
+ assertThat(paint.hasVerticalTextFlag()).isFalse()
+ assertThat(paint.textScaleX).isNotEqualTo(1f)
+ },
+ 0f,
+ 0f,
+ PAINT
+ )
+ }
+ }
+
+ @Test
+ fun `TateChuYoko SingleStyle Japanese Stretch`() {
+ createLayoutRun(TEXT, JAPANESE_START, JAPANESE_START + 4, TateChuYoko).run {
+ assertThat(start).isEqualTo(JAPANESE_START)
+ assertThat(end).isEqualTo(JAPANESE_START + 4)
+ assertThat(width).isEqualTo(ONE_EM * 1.1f) // allocate 1.1em for stretched text
+ assertThat(height).isEqualTo(getHorizontalLineHeight(JAPANESE_TEXT))
+ assertThat(leftSideOffset).isEqualTo(-HALF_EM * 1.1f) // leftSide is half of 1.1em
+ assertThat(rightSideOffset).isEqualTo(HALF_EM * 1.1f) // rightSide is half of 1.1em
+
+ draw(
+ MockCanvas { text, start, end, paint ->
+ assertThat(text).isEqualTo(TEXT)
+ assertThat(start).isEqualTo(JAPANESE_START)
+ assertThat(end).isEqualTo(JAPANESE_START + 4)
+ assertThat(paint.hasVerticalTextFlag()).isFalse()
+ assertThat(paint.textScaleX).isNotEqualTo(1f)
+ },
+ 0f,
+ 0f,
+ PAINT
+ )
+ }
+ }
+}
+
+private fun Paint.hasVerticalTextFlag() =
+ (flags and Paint.VERTICAL_TEXT_FLAG) == Paint.VERTICAL_TEXT_FLAG
diff --git a/text/text-vertical/src/androidTest/java/androidx/text/vertical/LineBreakerTest.kt b/text/text-vertical/src/androidTest/java/androidx/text/vertical/LineBreakerTest.kt
new file mode 100644
index 0000000..cf48b8f
--- /dev/null
+++ b/text/text-vertical/src/androidTest/java/androidx/text/vertical/LineBreakerTest.kt
@@ -0,0 +1,525 @@
+/*
+ * 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.Paint
+import android.text.SpannableString
+import android.text.TextPaint
+import androidx.text.vertical.TextOrientationSpan.TextCombineUpright
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+private const val SPAN_FLAG = SpannableString.SPAN_INCLUSIVE_EXCLUSIVE
+
+@RunWith(JUnit4::class)
+class LineBreakerTest {
+ private val PREFIX = "PREFIX_PREFIX_PREFIX"
+ private val SUFFIX = "SUFFIX_SUFFIX_SUFFIX"
+
+ private val EM = 10f // make 1em = 10px
+ private val HALF_EM = EM * 0.5f
+
+ private val PAINT = TextPaint().apply { textSize = EM }
+
+ private fun getVerticalAdvance(text: String): Float {
+ PAINT.flags = PAINT.flags or Paint.VERTICAL_TEXT_FLAG
+ return PAINT.measureText(text)
+ }
+
+ private fun getHorizontalAdvance(text: String, scaleFactor: Float = 1.0f): Float {
+ PAINT.flags = PAINT.flags and Paint.VERTICAL_TEXT_FLAG.inv()
+ PAINT.textSize = EM * scaleFactor
+ try {
+ return PAINT.measureText(text)
+ } finally {
+ PAINT.textSize = EM
+ }
+ }
+
+ private fun getHorizontalLineHeight(text: String): Float {
+ val fm = Paint.FontMetricsInt()
+ PAINT.getFontMetricsInt(text, 0, text.length, 0, text.length, false, fm)
+ return (fm.descent - fm.ascent).toFloat()
+ }
+
+ @Test
+ fun `BreakLine all upright Japanese`() {
+ val jpText = "吾輩は猫である。"
+ val text = PREFIX + jpText + SUFFIX
+ val jpStart = PREFIX.length
+ val jpEnd = jpStart + jpText.length
+ val breakText: (Float) -> LineBreaker.Result = { heightConstraint ->
+ LineBreaker.breakTextIntoLines(
+ text,
+ jpStart,
+ jpEnd,
+ PAINT,
+ heightConstraint,
+ TextOrientation.MIXED
+ )
+ }
+
+ // The following test verifies the behavior of breaking "吾輩は猫である。"
+ breakText(getVerticalAdvance(jpText)).also {
+ // ----
+ // 吾
+ // 輩
+ // は
+ // 猫
+ // で
+ // あ
+ // る
+ // 。
+ // ----
+ assertThat(it.lineCount).isEqualTo(1)
+ assertThat(it.width).isEqualTo(1 * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM)
+ assertThat(it.getLineStart(0)).isEqualTo(jpStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(jpStart + 8)
+ }
+
+ breakText(getVerticalAdvance(jpText) * 0.9f).also {
+ // ----
+ // る吾
+ // 。輩
+ // は
+ // 猫
+ // で
+ // あ
+ // ----
+ assertThat(it.lineCount).isEqualTo(2)
+ assertThat(it.width).isEqualTo(2 * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM)
+ assertThat(it.getLineStart(0)).isEqualTo(jpStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(jpStart + 6)
+ assertThat(it.getLineStart(1)).isEqualTo(jpStart + 6)
+ assertThat(it.getLineEnd(1)).isEqualTo(jpStart + 8)
+ }
+
+ breakText(getVerticalAdvance(jpText) * 0.4f).also {
+ // ----
+ // る猫吾
+ // 。で輩
+ // あは
+ // ----
+ assertThat(it.lineCount).isEqualTo(3)
+ assertThat(it.width).isEqualTo(3 * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM)
+ assertThat(it.getLineStart(0)).isEqualTo(jpStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(jpStart + 3)
+ assertThat(it.getLineStart(1)).isEqualTo(jpStart + 3)
+ assertThat(it.getLineEnd(1)).isEqualTo(jpStart + 6)
+ assertThat(it.getLineStart(2)).isEqualTo(jpStart + 6)
+ assertThat(it.getLineEnd(2)).isEqualTo(jpStart + 8)
+ }
+
+ breakText(getVerticalAdvance(jpText) * 0.25f).also {
+ // ----
+ // るでは吾
+ // 。あ猫輩
+ // ----
+ assertThat(it.lineCount).isEqualTo(4)
+ assertThat(it.width).isEqualTo(4 * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM)
+ assertThat(it.getLineStart(0)).isEqualTo(jpStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(jpStart + 2)
+ assertThat(it.getLineStart(1)).isEqualTo(jpStart + 2)
+ assertThat(it.getLineEnd(1)).isEqualTo(jpStart + 4)
+ assertThat(it.getLineStart(2)).isEqualTo(jpStart + 4)
+ assertThat(it.getLineEnd(2)).isEqualTo(jpStart + 6)
+ assertThat(it.getLineStart(3)).isEqualTo(jpStart + 6)
+ assertThat(it.getLineEnd(3)).isEqualTo(jpStart + 8)
+ }
+
+ breakText(getVerticalAdvance(jpText) * 0.1f).also {
+ // ----
+ // 。るあで猫は輩吾
+ // ----
+ assertThat(it.lineCount).isEqualTo(8)
+ assertThat(it.width).isEqualTo(8 * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM)
+ assertThat(it.getLineStart(0)).isEqualTo(jpStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(jpStart + 1)
+ assertThat(it.getLineStart(1)).isEqualTo(jpStart + 1)
+ assertThat(it.getLineEnd(1)).isEqualTo(jpStart + 2)
+ assertThat(it.getLineStart(2)).isEqualTo(jpStart + 2)
+ assertThat(it.getLineEnd(2)).isEqualTo(jpStart + 3)
+ assertThat(it.getLineStart(3)).isEqualTo(jpStart + 3)
+ assertThat(it.getLineEnd(3)).isEqualTo(jpStart + 4)
+ assertThat(it.getLineStart(4)).isEqualTo(jpStart + 4)
+ assertThat(it.getLineEnd(4)).isEqualTo(jpStart + 5)
+ assertThat(it.getLineStart(5)).isEqualTo(jpStart + 5)
+ assertThat(it.getLineEnd(5)).isEqualTo(jpStart + 6)
+ assertThat(it.getLineStart(6)).isEqualTo(jpStart + 6)
+ assertThat(it.getLineEnd(6)).isEqualTo(jpStart + 7)
+ assertThat(it.getLineStart(7)).isEqualTo(jpStart + 7)
+ assertThat(it.getLineEnd(7)).isEqualTo(jpStart + 8)
+ }
+ }
+
+ @Test
+ fun `BreakLine all rotate Latin`() {
+ val enText = "Hello, world."
+ val text = PREFIX + enText + SUFFIX
+ val enStart = PREFIX.length
+ val enEnd = enStart + enText.length
+ val breakText: (Float) -> LineBreaker.Result = { heightConstraint ->
+ LineBreaker.breakTextIntoLines(
+ text,
+ enStart,
+ enEnd,
+ PAINT,
+ heightConstraint,
+ TextOrientation.MIXED
+ )
+ }
+
+ // The following test verifies the behavior of breaking "Hello, World."
+ // Each letter are rotated.
+ breakText(getHorizontalAdvance(enText)).also {
+ // |Hello, World.|
+ // Note: the actual text is 90 degree rotated clockwise.
+ assertThat(it.lineCount).isEqualTo(1)
+ assertThat(it.width).isEqualTo(1 * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM)
+ assertThat(it.getLineStart(0)).isEqualTo(enStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(enStart + 13)
+ }
+
+ breakText(getHorizontalAdvance(enText) * 0.9f).also {
+ // |Hello, |
+ // |World. |
+ // Note: the actual text is 90 degree rotated clockwise.
+ assertThat(it.lineCount).isEqualTo(2)
+ assertThat(it.width).isEqualTo(2 * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM)
+ assertThat(it.getLineStart(0)).isEqualTo(enStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(enStart + 7)
+ assertThat(it.getLineStart(1)).isEqualTo(enStart + 7)
+ assertThat(it.getLineEnd(1)).isEqualTo(enStart + 13)
+ }
+
+ breakText(1.0f).also {
+ // |H|
+ // |e|
+ // |l|
+ // |l|
+ // |o|
+ // |,|
+ // | | // TODO: The line end whitespace should not be counted line width.
+ // |W|
+ // |o|
+ // |r|
+ // |l|
+ // |d|
+ // |.|
+ // Note: the actual text is 90 degree rotated clockwise.
+ assertThat(it.lineCount).isEqualTo(13)
+ assertThat(it.width).isEqualTo(13 * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM)
+ assertThat(it.getLineStart(0)).isEqualTo(enStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(enStart + 1)
+ assertThat(it.getLineStart(1)).isEqualTo(enStart + 1)
+ assertThat(it.getLineEnd(1)).isEqualTo(enStart + 2)
+ assertThat(it.getLineStart(2)).isEqualTo(enStart + 2)
+ assertThat(it.getLineEnd(2)).isEqualTo(enStart + 3)
+ assertThat(it.getLineStart(3)).isEqualTo(enStart + 3)
+ assertThat(it.getLineEnd(3)).isEqualTo(enStart + 4)
+ assertThat(it.getLineStart(4)).isEqualTo(enStart + 4)
+ assertThat(it.getLineEnd(4)).isEqualTo(enStart + 5)
+ assertThat(it.getLineStart(5)).isEqualTo(enStart + 5)
+ assertThat(it.getLineEnd(5)).isEqualTo(enStart + 6)
+ assertThat(it.getLineStart(6)).isEqualTo(enStart + 6)
+ assertThat(it.getLineEnd(6)).isEqualTo(enStart + 7)
+ assertThat(it.getLineStart(7)).isEqualTo(enStart + 7)
+ assertThat(it.getLineEnd(7)).isEqualTo(enStart + 8)
+ assertThat(it.getLineStart(8)).isEqualTo(enStart + 8)
+ assertThat(it.getLineEnd(8)).isEqualTo(enStart + 9)
+ assertThat(it.getLineStart(9)).isEqualTo(enStart + 9)
+ assertThat(it.getLineEnd(9)).isEqualTo(enStart + 10)
+ assertThat(it.getLineStart(10)).isEqualTo(enStart + 10)
+ assertThat(it.getLineEnd(10)).isEqualTo(enStart + 11)
+ assertThat(it.getLineStart(11)).isEqualTo(enStart + 11)
+ assertThat(it.getLineEnd(11)).isEqualTo(enStart + 12)
+ assertThat(it.getLineStart(12)).isEqualTo(enStart + 12)
+ assertThat(it.getLineEnd(12)).isEqualTo(enStart + 13)
+ }
+ }
+
+ @Test
+ fun `BreakLine all upright Latin`() {
+ val enText = "Hello, World."
+ val text = PREFIX + enText + SUFFIX
+ val enStart = PREFIX.length
+ val enEnd = enStart + enText.length
+ val breakText: (Float) -> LineBreaker.Result = { heightConstraint ->
+ LineBreaker.breakTextIntoLines(
+ text,
+ enStart,
+ enEnd,
+ PAINT,
+ heightConstraint,
+ TextOrientation.UPRIGHT
+ )
+ }
+
+ breakText(getVerticalAdvance(enText)).also {
+ // --
+ // H
+ // e
+ // l
+ // l
+ // o
+ // ,
+ //
+ // W
+ // o
+ // r
+ // l
+ // d
+ // .
+ // --
+ assertThat(it.lineCount).isEqualTo(1)
+ assertThat(it.width).isEqualTo(1 * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM)
+ assertThat(it.getLineStart(0)).isEqualTo(enStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(enStart + 13)
+ }
+
+ breakText(getVerticalAdvance(enText) * 0.9f).also {
+ // --
+ // WH
+ // oe
+ // rl
+ // ll
+ // do
+ // .,
+ //
+ //
+ //
+ //
+ // --
+ assertThat(it.lineCount).isEqualTo(2)
+ assertThat(it.width).isEqualTo(2 * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM)
+ assertThat(it.getLineStart(0)).isEqualTo(enStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(enStart + 7)
+ assertThat(it.getLineStart(1)).isEqualTo(enStart + 7)
+ assertThat(it.getLineEnd(1)).isEqualTo(enStart + 13)
+ }
+
+ breakText(1.0f).also {
+ // -------------
+ // .dlroW olleH
+ // -------------
+ assertThat(it.lineCount).isEqualTo(13)
+ assertThat(it.width).isEqualTo(13 * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM)
+ assertThat(it.getLineStart(0)).isEqualTo(enStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(enStart + 1)
+ assertThat(it.getLineStart(1)).isEqualTo(enStart + 1)
+ assertThat(it.getLineEnd(1)).isEqualTo(enStart + 2)
+ assertThat(it.getLineStart(2)).isEqualTo(enStart + 2)
+ assertThat(it.getLineEnd(2)).isEqualTo(enStart + 3)
+ assertThat(it.getLineStart(3)).isEqualTo(enStart + 3)
+ assertThat(it.getLineEnd(3)).isEqualTo(enStart + 4)
+ assertThat(it.getLineStart(4)).isEqualTo(enStart + 4)
+ assertThat(it.getLineEnd(4)).isEqualTo(enStart + 5)
+ assertThat(it.getLineStart(5)).isEqualTo(enStart + 5)
+ assertThat(it.getLineEnd(5)).isEqualTo(enStart + 6)
+ assertThat(it.getLineStart(6)).isEqualTo(enStart + 6)
+ assertThat(it.getLineEnd(6)).isEqualTo(enStart + 7)
+ assertThat(it.getLineStart(7)).isEqualTo(enStart + 7)
+ assertThat(it.getLineEnd(7)).isEqualTo(enStart + 8)
+ assertThat(it.getLineStart(8)).isEqualTo(enStart + 8)
+ assertThat(it.getLineEnd(8)).isEqualTo(enStart + 9)
+ assertThat(it.getLineStart(9)).isEqualTo(enStart + 9)
+ assertThat(it.getLineEnd(9)).isEqualTo(enStart + 10)
+ assertThat(it.getLineStart(10)).isEqualTo(enStart + 10)
+ assertThat(it.getLineEnd(10)).isEqualTo(enStart + 11)
+ assertThat(it.getLineStart(11)).isEqualTo(enStart + 11)
+ assertThat(it.getLineEnd(11)).isEqualTo(enStart + 12)
+ assertThat(it.getLineStart(12)).isEqualTo(enStart + 12)
+ assertThat(it.getLineEnd(12)).isEqualTo(enStart + 13)
+ }
+ }
+
+ @Test
+ fun `BreakLine TateChuYoko`() {
+ val jpDateText = "2024年12月25日"
+ val jpStart = PREFIX.length
+ val jpEnd = jpStart + jpDateText.length
+ val spanned =
+ SpannableString(PREFIX + jpDateText + SUFFIX).apply {
+ setSpan(TextCombineUpright(), jpStart, jpStart + 4, SPAN_FLAG) // 2024
+ setSpan(TextCombineUpright(), jpStart + 5, jpStart + 7, SPAN_FLAG) // 12
+ setSpan(TextCombineUpright(), jpStart + 8, jpStart + 10, SPAN_FLAG) // 25
+ }
+ val breakText: (Float) -> LineBreaker.Result = { heightConstraint ->
+ LineBreaker.breakTextIntoLines(
+ spanned,
+ jpStart,
+ jpEnd,
+ PAINT,
+ heightConstraint,
+ TextOrientation.MIXED
+ )
+ }
+
+ val h2024 = getHorizontalLineHeight("2024")
+ val hYear = getVerticalAdvance("年")
+ val h12 = getHorizontalLineHeight("12")
+ val hMonth = getVerticalAdvance("月")
+ val h25 = getHorizontalLineHeight("25")
+ val hDay = getVerticalAdvance("日")
+
+ breakText(h2024 + hYear + h12 + hMonth + h25 + hDay).also {
+ // ----
+ // [2024]
+ // 年
+ // [12]
+ // 月
+ // [25]
+ // 日
+ // ----
+ assertThat(it.lineCount).isEqualTo(1)
+ assertThat(it.width).isEqualTo(1.1f * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM * 1.1f)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM * 1.1f)
+ assertThat(it.getLineStart(0)).isEqualTo(jpStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(jpStart + 11)
+ }
+
+ breakText(h2024 + hYear + h12 + hMonth).also {
+ // ----
+ // [25] [2024]
+ // 日 年
+ // [12]
+ // 月
+ // ----
+ assertThat(it.lineCount).isEqualTo(2)
+ assertThat(it.width).isEqualTo(2 * 1.1f * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM * 1.1f)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM * 1.1f)
+ assertThat(it.getLineStart(0)).isEqualTo(jpStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(jpStart + 8)
+ assertThat(it.getLineStart(1)).isEqualTo(jpStart + 8)
+ assertThat(it.getLineEnd(1)).isEqualTo(jpStart + 11)
+ }
+
+ breakText(h2024 * 0.9f).also {
+ // ----
+ // 日[25]月[12]年[2024]
+ // ----
+ assertThat(it.lineCount).isEqualTo(6)
+ assertThat(it.width).isEqualTo(6 * 1.1f * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM * 1.1f)
+ assertThat(it.lineRightSide).isEqualTo(HALF_EM * 1.1f)
+ assertThat(it.getLineStart(0)).isEqualTo(jpStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(jpStart + 4)
+ assertThat(it.getLineStart(1)).isEqualTo(jpStart + 4)
+ assertThat(it.getLineEnd(1)).isEqualTo(jpStart + 5)
+ assertThat(it.getLineStart(2)).isEqualTo(jpStart + 5)
+ assertThat(it.getLineEnd(2)).isEqualTo(jpStart + 7)
+ assertThat(it.getLineStart(3)).isEqualTo(jpStart + 7)
+ assertThat(it.getLineEnd(3)).isEqualTo(jpStart + 8)
+ assertThat(it.getLineStart(4)).isEqualTo(jpStart + 8)
+ assertThat(it.getLineEnd(4)).isEqualTo(jpStart + 10)
+ assertThat(it.getLineStart(5)).isEqualTo(jpStart + 10)
+ assertThat(it.getLineEnd(5)).isEqualTo(jpStart + 11)
+ }
+ }
+
+ @Test
+ fun `BreakLine Ruby`() {
+ val jpText = "吾輩は猫である。"
+ val jpStart = PREFIX.length
+ val jpEnd = jpStart + jpText.length
+ // Set "わがはい" ruby to "吾輩".
+ val spanned =
+ SpannableString(PREFIX + jpText + SUFFIX).apply {
+ setSpan(RubySpan.Builder("わがはい").build(), jpStart, jpStart + 2, SPAN_FLAG) // 吾輩
+ }
+ val breakText: (Float) -> LineBreaker.Result = { heightConstraint ->
+ LineBreaker.breakTextIntoLines(
+ spanned,
+ jpStart,
+ jpEnd,
+ PAINT,
+ heightConstraint,
+ TextOrientation.MIXED
+ )
+ }
+
+ breakText(getVerticalAdvance(jpText)).also {
+ // ----
+ // 吾
+ // 輩
+ // は
+ // 猫
+ // で
+ // あ
+ // る
+ // 。
+ // ----
+ assertThat(it.lineCount).isEqualTo(1)
+ assertThat(it.width).isEqualTo(1.5f * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM)
+ assertThat(it.lineRightSide).isEqualTo(1 * EM) // 0.5em for right side, 0.5em for ruby
+ assertThat(it.getLineStart(0)).isEqualTo(jpStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(jpStart + 8)
+ }
+
+ breakText(getVerticalAdvance(jpText) * 0.1f).also {
+ // ----
+ // 。るあで猫は吾
+ // 輩
+ // ----
+ assertThat(it.lineCount).isEqualTo(7)
+ assertThat(it.width).isEqualTo(7 * 1.5f * EM)
+ assertThat(it.lineLeftSide).isEqualTo(-HALF_EM)
+ assertThat(it.lineRightSide).isEqualTo(1 * EM) // 0.5em for right side, 0.5em for ruby
+ assertThat(it.getLineStart(0)).isEqualTo(jpStart)
+ assertThat(it.getLineEnd(0)).isEqualTo(jpStart + 2)
+ assertThat(it.getLineStart(1)).isEqualTo(jpStart + 2)
+ assertThat(it.getLineEnd(1)).isEqualTo(jpStart + 3)
+ assertThat(it.getLineStart(2)).isEqualTo(jpStart + 3)
+ assertThat(it.getLineEnd(2)).isEqualTo(jpStart + 4)
+ assertThat(it.getLineStart(3)).isEqualTo(jpStart + 4)
+ assertThat(it.getLineEnd(3)).isEqualTo(jpStart + 5)
+ assertThat(it.getLineStart(4)).isEqualTo(jpStart + 5)
+ assertThat(it.getLineEnd(4)).isEqualTo(jpStart + 6)
+ assertThat(it.getLineStart(5)).isEqualTo(jpStart + 6)
+ assertThat(it.getLineEnd(5)).isEqualTo(jpStart + 7)
+ assertThat(it.getLineStart(6)).isEqualTo(jpStart + 7)
+ assertThat(it.getLineEnd(6)).isEqualTo(jpStart + 8)
+ }
+ }
+}
diff --git a/text/text-vertical/src/androidTest/java/androidx/text/vertical/LineLayoutTest.kt b/text/text-vertical/src/androidTest/java/androidx/text/vertical/LineLayoutTest.kt
new file mode 100644
index 0000000..8cda6e0
--- /dev/null
+++ b/text/text-vertical/src/androidTest/java/androidx/text/vertical/LineLayoutTest.kt
@@ -0,0 +1,172 @@
+/*
+ * 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.Paint
+import android.graphics.Paint.FontMetricsInt
+import android.text.SpannableString
+import android.text.TextPaint
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+private const val SPAN_FLAG = SpannableString.SPAN_INCLUSIVE_EXCLUSIVE
+
+@RunWith(JUnit4::class)
+class LineLayoutRunTest {
+ private val PREFIX = "PREFIX_PREFIX_PREFIX"
+ private val SUFFIX = "SUFFIX_SUFFIX_SUFFIX"
+ private val JAPANESE_TEXT = "あいうえお"
+ private val LATIN_TEXT = "abcde"
+
+ private val TEXT = PREFIX + LATIN_TEXT + JAPANESE_TEXT + SUFFIX
+ private val LATIN_START = PREFIX.length
+ private val LATIN_END = LATIN_START + LATIN_TEXT.length
+ private val JAPANESE_START = LATIN_END
+ private val JAPANESE_END = JAPANESE_START + JAPANESE_TEXT.length
+
+ private val ONE_EM = 10f // make 1em = 10px
+ private val HALF_EM = ONE_EM / 2
+
+ private val PAINT = TextPaint().apply { textSize = ONE_EM }
+
+ private fun getVerticalAdvance(text: String): Float {
+ PAINT.flags = PAINT.flags or Paint.VERTICAL_TEXT_FLAG
+ return PAINT.measureText(text)
+ }
+
+ private fun getHorizontalAdvance(text: String): Float {
+ PAINT.flags = PAINT.flags and Paint.VERTICAL_TEXT_FLAG.inv()
+ return PAINT.measureText(text)
+ }
+
+ private fun getHorizontalLineHeight(text: String): Float {
+ val fm = FontMetricsInt()
+ PAINT.getFontMetricsInt(text, 0, text.length, 0, text.length, false, fm)
+ return (fm.descent - fm.ascent).toFloat()
+ }
+
+ private fun createLayoutRun(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ orientation: ResolvedOrientation
+ ) = createLayoutRun(text, start, end, PAINT, orientation)
+
+ @Test
+ fun `LineLayout plain text with Mixed`() {
+ createLineLayout(TEXT, LATIN_START, JAPANESE_END, PAINT, TextOrientation.MIXED).run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(JAPANESE_END)
+ assertThat(width).isEqualTo(ONE_EM) // width is 1em.
+ assertThat(height)
+ .isEqualTo(getHorizontalAdvance(LATIN_TEXT) + getVerticalAdvance(JAPANESE_TEXT))
+ assertThat(leftSide).isEqualTo(-HALF_EM)
+ assertThat(rightSide).isEqualTo(HALF_EM)
+ assertThat(runs.size).isEqualTo(2)
+ assertThat(runs[0].start).isEqualTo(LATIN_START)
+ assertThat(runs[0].end).isEqualTo(LATIN_END)
+ assertThat(runs[0]).isInstanceOf(RotateLayoutRun::class.java)
+ assertThat(runs[1].start).isEqualTo(JAPANESE_START)
+ assertThat(runs[1].end).isEqualTo(JAPANESE_END)
+ assertThat(runs[1]).isInstanceOf(UprightLayoutRun::class.java)
+ }
+ }
+
+ @Test
+ fun `LineLayout plain text with Upright`() {
+ createLineLayout(TEXT, LATIN_START, JAPANESE_END, PAINT, TextOrientation.UPRIGHT).run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(JAPANESE_END)
+ assertThat(width).isEqualTo(ONE_EM) // width is 1em.
+ assertThat(height)
+ .isEqualTo(getVerticalAdvance(LATIN_TEXT) + getVerticalAdvance(JAPANESE_TEXT))
+ assertThat(leftSide).isEqualTo(-HALF_EM)
+ assertThat(rightSide).isEqualTo(HALF_EM)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0].start).isEqualTo(LATIN_START)
+ assertThat(runs[0].end).isEqualTo(JAPANESE_END)
+ assertThat(runs[0]).isInstanceOf(UprightLayoutRun::class.java)
+ }
+ }
+
+ @Test
+ fun `LineLayout plain text with Sideways`() {
+ createLineLayout(TEXT, LATIN_START, JAPANESE_END, PAINT, TextOrientation.SIDEWAYS).run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(JAPANESE_END)
+ assertThat(width).isEqualTo(ONE_EM) // width is 1em.
+ assertThat(height)
+ .isEqualTo(getHorizontalAdvance(LATIN_TEXT) + getHorizontalAdvance(JAPANESE_TEXT))
+ assertThat(leftSide).isEqualTo(-HALF_EM)
+ assertThat(rightSide).isEqualTo(HALF_EM)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0].start).isEqualTo(LATIN_START)
+ assertThat(runs[0].end).isEqualTo(JAPANESE_END)
+ assertThat(runs[0]).isInstanceOf(RotateLayoutRun::class.java)
+ }
+ }
+
+ @Test
+ fun `LineLayout span override text with Sideways`() {
+ val spanned =
+ SpannableString(TEXT).apply {
+ setSpan(TextOrientationSpan.Sideways(), JAPANESE_START, JAPANESE_END, SPAN_FLAG)
+ }
+ createLineLayout(spanned, LATIN_START, JAPANESE_END, PAINT, TextOrientation.MIXED).run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(JAPANESE_END)
+ assertThat(width).isEqualTo(ONE_EM) // width is 1em.
+ assertThat(height)
+ .isEqualTo(getHorizontalAdvance(LATIN_TEXT) + getHorizontalAdvance(JAPANESE_TEXT))
+ assertThat(leftSide).isEqualTo(-HALF_EM)
+ assertThat(rightSide).isEqualTo(HALF_EM)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0].start).isEqualTo(LATIN_START)
+ assertThat(runs[0].end).isEqualTo(JAPANESE_END)
+ assertThat(runs[0]).isInstanceOf(RotateLayoutRun::class.java)
+ }
+ }
+
+ @Test
+ fun `LineLayout span override text with TateChuYoko`() {
+ val spanned =
+ SpannableString(TEXT).apply {
+ setSpan(TextOrientationSpan.TextCombineUpright(), LATIN_START, LATIN_END, SPAN_FLAG)
+ }
+ createLineLayout(spanned, LATIN_START, JAPANESE_END, PAINT, TextOrientation.MIXED).run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(JAPANESE_END)
+ // Overall line width is extended to 1.1em because of the long TateChuYoko span.
+ assertThat(width).isEqualTo(ONE_EM * 1.1f) // width is 1em.
+ assertThat(height)
+ .isEqualTo(getHorizontalLineHeight(LATIN_TEXT) + getVerticalAdvance(JAPANESE_TEXT))
+
+ // Overall line sides are extended to 1.1em because of the long TateChuYoko span.
+ assertThat(leftSide).isEqualTo(-HALF_EM * 1.1f)
+ assertThat(rightSide).isEqualTo(HALF_EM * 1.1f)
+ assertThat(runs.size).isEqualTo(2)
+ assertThat(runs[0].start).isEqualTo(LATIN_START)
+ assertThat(runs[0].end).isEqualTo(LATIN_END)
+ assertThat(runs[0]).isInstanceOf(TateChuYokoLayoutRun::class.java)
+ assertThat(runs[1].start).isEqualTo(JAPANESE_START)
+ assertThat(runs[1].end).isEqualTo(JAPANESE_END)
+ assertThat(runs[1]).isInstanceOf(UprightLayoutRun::class.java)
+ }
+ }
+}
diff --git a/text/text-vertical/src/androidTest/java/androidx/text/vertical/OrientationsTest.kt b/text/text-vertical/src/androidTest/java/androidx/text/vertical/OrientationsTest.kt
new file mode 100644
index 0000000..1572e41
--- /dev/null
+++ b/text/text-vertical/src/androidTest/java/androidx/text/vertical/OrientationsTest.kt
@@ -0,0 +1,261 @@
+/*
+ * 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.
+ */
+
+import android.text.SpannableString
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.text.vertical.OrientationMode
+import androidx.text.vertical.ResolvedOrientation
+import androidx.text.vertical.TextOrientation
+import androidx.text.vertical.TextOrientationSpan
+import androidx.text.vertical.forEachOrientation
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val SPAN_FLAG = SpannableString.SPAN_INCLUSIVE_EXCLUSIVE
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class OrientationsTest {
+ private sealed interface Run
+
+ private data class Upright(val start: Int, val end: Int) : Run
+
+ private data class Rotate(val start: Int, val end: Int) : Run
+
+ private data class TateChuYoko(val start: Int, val end: Int) : Run
+
+ private fun resolve(
+ text: CharSequence,
+ start: Int = 0,
+ end: Int = text.length,
+ @OrientationMode textOrientation: Int = TextOrientation.MIXED
+ ): List<Run> {
+ val out = mutableListOf<Run>()
+ forEachOrientation(text, start, end, textOrientation) { oStart, oEnd, orientation ->
+ when (orientation) {
+ ResolvedOrientation.Upright -> out.add(Upright(oStart, oEnd))
+ ResolvedOrientation.Rotate -> out.add(Rotate(oStart, oEnd))
+ ResolvedOrientation.TateChuYoko -> out.add(TateChuYoko(oStart, oEnd))
+ }
+ }
+ return out
+ }
+
+ private fun resolve(
+ text: CharSequence,
+ @OrientationMode textOrientation: Int = TextOrientation.MIXED
+ ) = resolve(text, 0, text.length, textOrientation)
+
+ @Test
+ fun emptyText() {
+ // Empty text
+ assertThat(resolve("")).isEmpty()
+ assertThat(resolve("", 0, 0)).isEmpty()
+
+ // Empty range
+ assertThat(resolve("abc", 0, 0)).isEmpty()
+ assertThat(resolve("abc", 1, 1)).isEmpty()
+
+ // Reversed range (invalid range)
+ assertThat(resolve("abc", 2, 1)).isEmpty()
+ }
+
+ @Test
+ fun noOverrideText_MixedOrientation() {
+ // Whole text
+ // Japanese letters: resolved to upright.
+ var runs = resolve("あいうえお")
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Upright(0, 5))
+
+ // English letters: resolved to Rotate.
+ runs = resolve("abcde")
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Rotate(0, 5))
+
+ // Japanese and English mixed text: resolve as multiple runs
+ runs = resolve("あいうえおabcde")
+ assertThat(runs.size).isEqualTo(2)
+ assertThat(runs[0]).isEqualTo(Upright(0, 5))
+ assertThat(runs[1]).isEqualTo(Rotate(5, 10))
+
+ // Substring
+ runs = resolve("あいうえお", 1, 3)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Upright(1, 3))
+
+ runs = resolve("abcde", 1, 3)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Rotate(1, 3))
+
+ runs = resolve("あいうえおabcde", 4, 7)
+ assertThat(runs.size).isEqualTo(2)
+ assertThat(runs[0]).isEqualTo(Upright(4, 5))
+ assertThat(runs[1]).isEqualTo(Rotate(5, 7))
+ }
+
+ @Test
+ fun noOverrideText_UprightOrientation() {
+ var runs = resolve("あいうえお", TextOrientation.UPRIGHT)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Upright(0, 5))
+
+ runs = resolve("abcde", TextOrientation.UPRIGHT)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Upright(0, 5))
+
+ runs = resolve("あいうえおabcde", TextOrientation.UPRIGHT)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Upright(0, 10))
+
+ // Substring
+ runs = resolve("あいうえお", 1, 3, TextOrientation.UPRIGHT)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Upright(1, 3))
+
+ runs = resolve("abcde", 1, 3, TextOrientation.UPRIGHT)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Upright(1, 3))
+
+ runs = resolve("あいうえおabcde", 4, 7, textOrientation = TextOrientation.UPRIGHT)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Upright(4, 7))
+ }
+
+ @Test
+ fun noOverrideText_SidewaysOrientation() {
+ var runs = resolve("あいうえお", TextOrientation.SIDEWAYS)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Rotate(0, 5))
+
+ runs = resolve("abcde", TextOrientation.SIDEWAYS)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Rotate(0, 5))
+
+ runs = resolve("あいうえおabcde", TextOrientation.SIDEWAYS)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Rotate(0, 10))
+
+ // Substring
+ runs = resolve("あいうえお", 1, 3, TextOrientation.SIDEWAYS)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Rotate(1, 3))
+
+ runs = resolve("abcde", 1, 3, TextOrientation.SIDEWAYS)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Rotate(1, 3))
+
+ runs = resolve("あいうえおabcde", 4, 7, TextOrientation.SIDEWAYS)
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Rotate(4, 7))
+ }
+
+ @Test
+ fun overrideText_UprightOverride() {
+ var runs =
+ resolve(
+ SpannableString("あいうえお").apply {
+ setSpan(TextOrientationSpan.Upright(), 1, 2, SPAN_FLAG)
+ }
+ )
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Upright(0, 5))
+
+ runs =
+ resolve(
+ SpannableString("abcde").apply {
+ setSpan(TextOrientationSpan.Upright(), 1, 2, SPAN_FLAG)
+ }
+ )
+ assertThat(runs.size).isEqualTo(3)
+ assertThat(runs[0]).isEqualTo(Rotate(0, 1))
+ assertThat(runs[1]).isEqualTo(Upright(1, 2))
+ assertThat(runs[2]).isEqualTo(Rotate(2, 5))
+
+ runs =
+ resolve(
+ SpannableString("あいうえおabcde").apply {
+ setSpan(TextOrientationSpan.Upright(), 4, 7, SPAN_FLAG)
+ }
+ )
+ assertThat(runs.size).isEqualTo(2)
+ assertThat(runs[0]).isEqualTo(Upright(0, 7))
+ assertThat(runs[1]).isEqualTo(Rotate(7, 10))
+ }
+
+ @Test
+ fun overrideText_SidewaysOverride() {
+ var runs =
+ resolve(
+ SpannableString("あいうえお").apply {
+ setSpan(TextOrientationSpan.Sideways(), 1, 2, SPAN_FLAG)
+ }
+ )
+ assertThat(runs.size).isEqualTo(3)
+ assertThat(runs[0]).isEqualTo(Upright(0, 1))
+ assertThat(runs[1]).isEqualTo(Rotate(1, 2))
+ assertThat(runs[2]).isEqualTo(Upright(2, 5))
+
+ runs =
+ resolve(
+ SpannableString("abcde").apply {
+ setSpan(TextOrientationSpan.Sideways(), 1, 2, SPAN_FLAG)
+ }
+ )
+ assertThat(runs.size).isEqualTo(1)
+ assertThat(runs[0]).isEqualTo(Rotate(0, 5))
+
+ runs =
+ resolve(
+ SpannableString("あいうえおabcde").apply {
+ setSpan(TextOrientationSpan.Sideways(), 4, 7, SPAN_FLAG)
+ }
+ )
+ assertThat(runs.size).isEqualTo(2)
+ assertThat(runs[0]).isEqualTo(Upright(0, 4))
+ assertThat(runs[1]).isEqualTo(Rotate(4, 10))
+ }
+
+ @Test
+ fun tateChuToko() {
+ var runs =
+ resolve(
+ SpannableString("abcde").apply {
+ setSpan(TextOrientationSpan.TextCombineUpright(), 1, 2, SPAN_FLAG)
+ }
+ )
+ assertThat(runs.size).isEqualTo(3)
+ assertThat(runs[0]).isEqualTo(Rotate(0, 1))
+ assertThat(runs[1]).isEqualTo(TateChuYoko(1, 2))
+ assertThat(runs[2]).isEqualTo(Rotate(2, 5))
+
+ // TateChuYoko should not be extended even if they are connected.
+ runs =
+ resolve(
+ SpannableString("abcde").apply {
+ setSpan(TextOrientationSpan.TextCombineUpright(), 1, 2, SPAN_FLAG)
+ setSpan(TextOrientationSpan.TextCombineUpright(), 2, 4, SPAN_FLAG)
+ }
+ )
+ assertThat(runs.size).isEqualTo(4)
+ assertThat(runs[0]).isEqualTo(Rotate(0, 1))
+ assertThat(runs[1]).isEqualTo(TateChuYoko(1, 2))
+ assertThat(runs[2]).isEqualTo(TateChuYoko(2, 4))
+ assertThat(runs[3]).isEqualTo(Rotate(4, 5))
+ }
+}
diff --git a/text/text-vertical/src/androidTest/java/androidx/text/vertical/RubyTest.kt b/text/text-vertical/src/androidTest/java/androidx/text/vertical/RubyTest.kt
new file mode 100644
index 0000000..c18521a
--- /dev/null
+++ b/text/text-vertical/src/androidTest/java/androidx/text/vertical/RubyTest.kt
@@ -0,0 +1,252 @@
+/*
+ * 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.text.SpannableString
+import android.text.TextPaint
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+private const val SPAN_FLAG = SpannableString.SPAN_INCLUSIVE_EXCLUSIVE
+
+class RubyTest {
+ private val PREFIX = "PREFIX_PREFIX_PREFIX"
+ private val SUFFIX = "SUFFIX_SUFFIX_SUFFIX"
+ private val LATIN_TEXT = "abcde"
+ private val RUBY_TEXT = "ABCDE"
+
+ private val TEXT = PREFIX + LATIN_TEXT + SUFFIX
+ private val LATIN_START = PREFIX.length
+ private val LATIN_END = LATIN_START + LATIN_TEXT.length
+
+ private val ONE_EM = 10f // make 1em = 10px
+ private val HALF_EM = ONE_EM / 2
+
+ private val PAINT = TextPaint().apply { textSize = ONE_EM }
+
+ private fun getVerticalAdvance(text: String, scaleFactor: Float = 1.0f): Float {
+ PAINT.flags = PAINT.flags or Paint.VERTICAL_TEXT_FLAG
+ PAINT.textSize = ONE_EM * scaleFactor
+ try {
+ return PAINT.measureText(text)
+ } finally {
+ PAINT.textSize = ONE_EM
+ }
+ }
+
+ private fun getHorizontalAdvance(text: String, scaleFactor: Float = 1.0f): Float {
+ PAINT.flags = PAINT.flags and Paint.VERTICAL_TEXT_FLAG.inv()
+ PAINT.textSize = ONE_EM * scaleFactor
+ try {
+ return PAINT.measureText(text)
+ } finally {
+ PAINT.textSize = ONE_EM
+ }
+ }
+
+ private class MockCanvas() : Canvas() {
+ data class DrawTextRunCall(
+ val text: CharSequence,
+ val start: Int,
+ val end: Int,
+ val paint: Paint
+ )
+
+ val invocations = mutableListOf<DrawTextRunCall>()
+
+ override fun drawText(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ x: Float,
+ y: Float,
+ paint: Paint
+ ) {
+ super.drawText(text, start, end, x, y, paint)
+ invocations.add(DrawTextRunCall(text, start, end, Paint(paint)))
+ }
+ }
+
+ @Test
+ fun `Ruby Builder build and get - default`() {
+ RubySpan.Builder(RUBY_TEXT).build().run {
+ assertThat(text).isEqualTo(RUBY_TEXT)
+ assertThat(orientation).isEqualTo(TextOrientation.MIXED)
+ assertThat(textScale).isEqualTo(0.5f)
+ }
+ }
+
+ @Test
+ fun `Ruby Builder build and get - customize`() {
+ RubySpan.Builder(RUBY_TEXT)
+ .setOrientation(TextOrientation.UPRIGHT)
+ .setTextScale(0.3f)
+ .build()
+ .run {
+ assertThat(text).isEqualTo(RUBY_TEXT)
+ assertThat(orientation).isEqualTo(TextOrientation.UPRIGHT)
+ assertThat(textScale).isEqualTo(0.3f)
+ }
+ }
+
+ @Test
+ fun `RubyLayout create - Ruby is shorter than base text`() {
+ val rubySpan = RubySpan.Builder(RUBY_TEXT).build()
+ RubyLayoutRun(TEXT, LATIN_START, LATIN_END, TextOrientation.MIXED, PAINT, rubySpan).run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_END)
+ assertThat(width).isEqualTo(ONE_EM * 1.5f) // 1em for base text, 0.5em for ruby.
+ // Since the ruby is shorter than base text, the base text is height for the run.
+ assertThat(height).isEqualTo(getHorizontalAdvance(LATIN_TEXT))
+ assertThat(leftSideOffset).isEqualTo(-HALF_EM) // leftSide is half of 1em
+ assertThat(rightSideOffset)
+ .isEqualTo(ONE_EM) // right half of 1em + 0.5em for ruby width.
+
+ val mock = MockCanvas()
+ draw(mock, 0f, 0f, PAINT)
+ assertThat(mock.invocations.size).isEqualTo(2)
+ val bodyIndex = if (mock.invocations[0].text == TEXT) 0 else 1
+ val rubyIndex = if (bodyIndex == 0) 1 else 0
+
+ mock.invocations[bodyIndex].run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_END)
+ assertThat(paint.hasVerticalTextFlag()).isFalse()
+ assertThat(paint.textSize).isEqualTo(PAINT.textSize)
+ }
+ mock.invocations[rubyIndex].run {
+ assertThat(start).isEqualTo(0)
+ assertThat(end).isEqualTo(RUBY_TEXT.length)
+ assertThat(paint.hasVerticalTextFlag()).isFalse()
+ assertThat(paint.textSize).isEqualTo(PAINT.textSize * 0.5f)
+ }
+ }
+ }
+
+ @Test
+ fun `RubyLayout create - Ruby is longer than base text`() {
+ val LONG_RUBY_TEXT = RUBY_TEXT.repeat(10)
+ val rubySpan = RubySpan.Builder(LONG_RUBY_TEXT).build()
+ RubyLayoutRun(TEXT, LATIN_START, LATIN_END, TextOrientation.MIXED, PAINT, rubySpan).run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_END)
+ assertThat(width).isEqualTo(ONE_EM * 1.5f) // 1em for base text, 0.5em for ruby.
+ // Since the ruby is longer than base text, the ruby text is height for the run.
+ assertThat(height).isEqualTo(getHorizontalAdvance(LONG_RUBY_TEXT, 0.5f /* scale */))
+ assertThat(leftSideOffset).isEqualTo(-HALF_EM) // leftSide is half of 1em
+ assertThat(rightSideOffset)
+ .isEqualTo(ONE_EM) // right half of 1em + 0.5em for ruby width.
+
+ val mock = MockCanvas()
+ draw(mock, 0f, 0f, PAINT)
+ assertThat(mock.invocations.size).isEqualTo(2)
+ val bodyIndex = if (mock.invocations[0].text == TEXT) 0 else 1
+ val rubyIndex = if (bodyIndex == 0) 1 else 0
+
+ mock.invocations[bodyIndex].run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_END)
+ assertThat(paint.hasVerticalTextFlag()).isFalse()
+ assertThat(paint.textSize).isEqualTo(PAINT.textSize)
+ }
+ mock.invocations[rubyIndex].run {
+ assertThat(start).isEqualTo(0)
+ assertThat(end).isEqualTo(LONG_RUBY_TEXT.length)
+ assertThat(paint.hasVerticalTextFlag()).isFalse()
+ assertThat(paint.textSize).isEqualTo(PAINT.textSize * 0.5f)
+ }
+ }
+ }
+
+ @Test
+ fun `RubyLayout create - Ruby upright orientation`() {
+ val LONG_RUBY_TEXT = RUBY_TEXT.repeat(10)
+ val rubySpan =
+ RubySpan.Builder(LONG_RUBY_TEXT).setOrientation(TextOrientation.UPRIGHT).build()
+ RubyLayoutRun(TEXT, LATIN_START, LATIN_END, TextOrientation.MIXED, PAINT, rubySpan).run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_END)
+ assertThat(width).isEqualTo(ONE_EM * 1.5f) // 1em for base text, 0.5em for ruby.
+ // The ruby text is layout with UPRIGHT orientation. Therefore, the vertical advance
+ // is used for the height.
+ assertThat(height).isEqualTo(getVerticalAdvance(LONG_RUBY_TEXT, 0.5f /* scale */))
+ assertThat(leftSideOffset).isEqualTo(-HALF_EM) // leftSide is half of 1em
+ assertThat(rightSideOffset)
+ .isEqualTo(ONE_EM) // right half of 1em + 0.5em for ruby width.
+
+ val mock = MockCanvas()
+ draw(mock, 0f, 0f, PAINT)
+ assertThat(mock.invocations.size).isEqualTo(2)
+ val bodyIndex = if (mock.invocations[0].text == TEXT) 0 else 1
+ val rubyIndex = if (bodyIndex == 0) 1 else 0
+
+ mock.invocations[bodyIndex].run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_END)
+ assertThat(paint.hasVerticalTextFlag()).isFalse()
+ assertThat(paint.textSize).isEqualTo(PAINT.textSize)
+ }
+ mock.invocations[rubyIndex].run {
+ assertThat(start).isEqualTo(0)
+ assertThat(end).isEqualTo(LONG_RUBY_TEXT.length)
+ assertThat(paint.hasVerticalTextFlag()).isTrue()
+ assertThat(paint.textSize).isEqualTo(PAINT.textSize * 0.5f)
+ }
+ }
+ }
+
+ @Test
+ fun `RubyLayout create - Ruby scale`() {
+ val LONG_RUBY_TEXT = RUBY_TEXT.repeat(10)
+ val rubySpan = RubySpan.Builder(LONG_RUBY_TEXT).setTextScale(0.3f).build()
+ RubyLayoutRun(TEXT, LATIN_START, LATIN_END, TextOrientation.MIXED, PAINT, rubySpan).run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_END)
+ assertThat(width).isEqualTo(ONE_EM * 1.3f) // 1em for base text, 0.5em for ruby.
+ // The ruby text is layout with UPRIGHT orientation. Therefore, the vertical advance
+ // is used for the height.
+ assertThat(height).isEqualTo(getHorizontalAdvance(LONG_RUBY_TEXT, 0.3f /* scale */))
+ assertThat(leftSideOffset).isEqualTo(-HALF_EM) // leftSide is half of 1em
+ // right half of 1em + 0.5em for ruby width.
+ assertThat(rightSideOffset).isEqualTo(HALF_EM + 0.3f * ONE_EM)
+
+ val mock = MockCanvas()
+ draw(mock, 0f, 0f, PAINT)
+ assertThat(mock.invocations.size).isEqualTo(2)
+ val bodyIndex = if (mock.invocations[0].text == TEXT) 0 else 1
+ val rubyIndex = if (bodyIndex == 0) 1 else 0
+
+ mock.invocations[bodyIndex].run {
+ assertThat(start).isEqualTo(LATIN_START)
+ assertThat(end).isEqualTo(LATIN_END)
+ assertThat(paint.hasVerticalTextFlag()).isFalse()
+ assertThat(paint.textSize).isEqualTo(PAINT.textSize)
+ }
+ mock.invocations[rubyIndex].run {
+ assertThat(start).isEqualTo(0)
+ assertThat(end).isEqualTo(LONG_RUBY_TEXT.length)
+ assertThat(paint.hasVerticalTextFlag()).isFalse()
+ assertThat(paint.textSize).isEqualTo(PAINT.textSize * 0.3f)
+ }
+ }
+ }
+}
+
+private fun Paint.hasVerticalTextFlag() =
+ (flags and Paint.VERTICAL_TEXT_FLAG) == Paint.VERTICAL_TEXT_FLAG
diff --git a/text/text-vertical/src/androidTest/java/androidx/text/vertical/VerticalTextLayoutTest.kt b/text/text-vertical/src/androidTest/java/androidx/text/vertical/VerticalTextLayoutTest.kt
new file mode 100644
index 0000000..8d74ca0
--- /dev/null
+++ b/text/text-vertical/src/androidTest/java/androidx/text/vertical/VerticalTextLayoutTest.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.text.TextPaint
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class VerticalTextLayoutTest {
+ // The detailed behavior tests are written in LineBreakerTests and underlying LayoutRunTests.
+ // In this test case, just check the set in builder and get in instance.
+
+ val PAINT =
+ TextPaint().apply() {
+ textSize = 10f // make 1em = 10px
+ }
+
+ val JP_TEXT = "吾輩は猫である。\n1904年(明治39年)生まれである。\n英名はI Am a Catである。"
+
+ @Test
+ fun `create default params`() {
+ VerticalTextLayout.Builder(JP_TEXT, 0, JP_TEXT.length, PAINT, 100f).build().run {
+ assertThat(text).isEqualTo(JP_TEXT)
+ assertThat(start).isEqualTo(0)
+ assertThat(end).isEqualTo(JP_TEXT.length)
+ assertThat(paint).isSameInstanceAs(PAINT)
+ assertThat(height).isEqualTo(100f)
+ assertThat(orientation).isEqualTo(TextOrientation.MIXED)
+ }
+ }
+
+ @Test
+ fun `create upright orientation`() {
+ VerticalTextLayout.Builder(JP_TEXT, 0, JP_TEXT.length, PAINT, 100f)
+ .setOrientation(TextOrientation.UPRIGHT)
+ .build()
+ .run {
+ assertThat(text).isEqualTo(JP_TEXT)
+ assertThat(start).isEqualTo(0)
+ assertThat(end).isEqualTo(JP_TEXT.length)
+ assertThat(paint).isSameInstanceAs(PAINT)
+ assertThat(height).isEqualTo(100f)
+ assertThat(orientation).isEqualTo(TextOrientation.UPRIGHT)
+ }
+ }
+}
diff --git a/text/text-vertical/src/main/java/androidx/text/vertical/LayoutRun.kt b/text/text-vertical/src/main/java/androidx/text/vertical/LayoutRun.kt
new file mode 100644
index 0000000..f9c49ea
--- /dev/null
+++ b/text/text-vertical/src/main/java/androidx/text/vertical/LayoutRun.kt
@@ -0,0 +1,490 @@
+/*
+ * 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 ->
+ 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)
+
+ canvas.drawText(text, rStart, rEnd, x, y, rPaint)
+
+ // 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 ->
+ val width = rPaint.measureText(text, rStart, rEnd)
+ drawBackground(canvas, x, ascent, width, descent - ascent, bgColor)
+ canvas.drawText(text, rStart, rEnd, x, 0f, rPaint)
+ 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 ->
+ if (bgColor != 0) {
+ tempPaint { bgWorkPaint ->
+ bgWorkPaint.color = bgColor
+ canvas.drawRect(
+ originX + leftSideOffset,
+ y,
+ originX + rightSideOffset,
+ y + height,
+ bgWorkPaint
+ )
+ }
+ }
+
+ canvas.drawText(text, rStart, rEnd, originX, y, rPaint)
+ 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) -> 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 */) }
+ 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)
+
+ workPaint.set(basePaint)
+ styles.forEach { it.updateDrawState(workPaint) }
+ applyVerticalFlag(workPaint, isVertical) {
+ block(current, rEnd, workPaint, workPaint.bgColor)
+ }
+ 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
+ }
+}
diff --git a/text/text-vertical/src/main/java/androidx/text/vertical/LineBreaker.kt b/text/text-vertical/src/main/java/androidx/text/vertical/LineBreaker.kt
new file mode 100644
index 0000000..53b6ca2
--- /dev/null
+++ b/text/text-vertical/src/main/java/androidx/text/vertical/LineBreaker.kt
@@ -0,0 +1,454 @@
+/*
+ * 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.text.TextPaint
+import androidx.annotation.Px
+import androidx.text.vertical.LineBreaker.Result
+import java.text.BreakIterator
+import java.util.Locale
+import kotlin.math.max
+import kotlin.math.min
+
+// Illustrate meaning of baseline, left, right and drawing offset.
+// (Vertical LR case, line grows from right to left.)
+//
+// 2nd line 1st line
+//
+// width width
+// <----------------> <---------------->
+// leftSide rightSide leftSide rightSide
+// <-------- -------> <-------- ------->
+// 2nd baseline 1st baseline
+// | | | | +Drawing offset (top-right corner)
+// | +------+------+ | +------+------+ |
+// | | | | | | | | |
+// | | n-th char | | | 1st char | |
+// | | | | | | | | |
+// | | | | | | | | |
+// | +------+------+ | +------+------+ |
+// | | | | | | | | |
+// | | n+1-th char | | | 2nd char | |
+// | | | | | | | | |
+// | | | | | | | | |
+// | +------+------+ | +------+------+ |
+// | | | | | | | | |
+// | | | | | | | | |
+// | | | | | | | | |
+// ^1st line left ^1st line right
+// ^2nd line left ^2nd line right
+//
+// The rightSide is a positive distance from the baseline to the line right.
+//
+// The leftSide is a negative distance from the baseline to the line left.
+//
+// The width is a positive distance from the line left to line right.
+// The width is calculated with right - left.
+
+internal object LineBreaker {
+ /**
+ * Represents lines of text that has been laid out.
+ *
+ * @property width The total width of the line.
+ * @property lineLeftSide The distance from each line left to each line baseline.
+ * @property lineRightSide The distance from each line baseline to each line right.
+ */
+ class Result(
+ private val lineLayouts: List<LineLayout>,
+ val width: Float,
+ val lineLeftSide: Float,
+ val lineRightSide: Float
+ ) {
+ /** The number of lines in the text layout. */
+ val lineCount: Int
+ get() = lineLayouts.size
+
+ /**
+ * Retrieves the inclusive starting character index of a specified line.
+ *
+ * @param lineNo The line number.
+ * @return The inclusive starting character index of the specified line.
+ */
+ fun getLineStart(lineNo: Int) = lineLayouts[lineNo].start
+
+ /**
+ * Retrieves the exclusive ending character index of a specified line.
+ *
+ * @param lineNo The line number.
+ * @return The exclusive ending character index of the specified line.
+ */
+ fun getLineEnd(lineNo: Int) = lineLayouts[lineNo].end
+
+ /**
+ * Draws the text lines onto the given canvas.
+ *
+ * @param canvas The Canvas to draw on.
+ * @param x The starting X position for drawing. The right-top is the drawing origin.
+ * @param y The starting Y position for drawing. The right-top is the drawing origin.
+ * @param paint The TextPaint object to use for text rendering.
+ */
+ fun draw(canvas: Canvas, x: Float, y: Float, paint: TextPaint) {
+ val lineWidth = lineRightSide - lineLeftSide
+ lineLayouts.forEachIndexed { i, line ->
+ // Baseline offset of the i-th line.
+ val baselineOffset = -lineWidth * i - lineRightSide
+ line.draw(canvas, x + baselineOffset, y, paint)
+ }
+ }
+ }
+
+ /**
+ * Performs a line break and layouts each lines.
+ *
+ * @param text The text to be processed.
+ * @param start The inclusive starting index of the range.
+ * @param end The exclusive ending index of the range.
+ * @param paint The TextPaint used for measuring and drawing text.
+ * @param heightConstraint The height constraint in pixels.
+ * @param textOrientation The desired orientation for the text (MIXED, HORIZONTAL, VERTICAL).
+ * Defaults to MIXED.
+ * @return A Result object containing the broken text lines.
+ */
+ fun breakTextIntoLines(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ paint: TextPaint,
+ @Px heightConstraint: Float,
+ textOrientation: Int = TextOrientation.MIXED,
+ ): Result {
+ val ctx = Context(text, paint, heightConstraint)
+ text.forEachParagraph(start, end) { paraStart, paraEnd ->
+ forEachRubySpanTransition(text, paraStart, paraEnd) { runStart, runEnd, rubySpan ->
+ if (rubySpan == null) {
+ forEachOrientation(text, runStart, runEnd, textOrientation) { oStart, oEnd, o ->
+ if (o == ResolvedOrientation.TateChuYoko) {
+ ctx.processTateChuYoko(oStart, oEnd)
+ } else {
+ ctx.processRun(oStart, oEnd, o)
+ }
+ }
+ } else {
+ ctx.processRubyRun(runStart, runEnd, rubySpan, textOrientation)
+ }
+ }
+
+ // Add a line break after each paragraph except the last one.
+ if (paraEnd != end) {
+ ctx.breakLine()
+ }
+ }
+ return ctx.finish()
+ }
+}
+
+/**
+ * A custom word breaker for the line breaking specialized for vertical text.
+ *
+ * @property text The text to break into words.
+ * @property locale The locale for language-specific word breaking rules.
+ */
+private data class WordBreaker(val text: CharSequence, val locale: Locale) {
+ private var currentRunStart = -1 // unused init value
+ private var currentRunEnd = -1 // unused init value
+ private var currentRunOrientation = ResolvedOrientation.Upright // unused init value
+ private var currentHead = -1 // unused init value
+
+ val current: Int
+ get() = currentHead
+
+ private val br =
+ BreakIterator.getLineInstance(locale).apply {
+ // TODO: Introduce CharacterIterator
+ setText(this@WordBreaker.text.toString())
+ }
+
+ fun updateForRun(start: Int, end: Int, orientation: ResolvedOrientation) {
+ currentRunStart = start
+ currentRunEnd = end
+ currentRunOrientation = orientation
+ currentHead = currentRunStart
+ advanceBreakOffset()
+ }
+
+ /**
+ * Advances the word break offset within the current run.
+ *
+ * @return The new break offset.
+ */
+ fun advanceBreakOffset(): Int {
+ currentHead =
+ when (currentRunOrientation) {
+ ResolvedOrientation.Rotate -> br.following(currentHead)
+ ResolvedOrientation.Upright -> br.following(currentHead)
+ else -> throw RuntimeException("TateChuYoko and Ruby should not be broken.")
+ }
+ if (currentHead == BreakIterator.DONE || currentHead > currentRunEnd) {
+ currentHead = currentRunEnd
+ }
+ return currentHead
+ }
+}
+
+/**
+ * Manages the state and logic for breaking text into lines.
+ *
+ * @property text The input text.
+ * @property paint The paint used for measuring text.
+ * @property heightConstraint The height constraint.
+ */
+private data class Context(
+ val text: CharSequence,
+ val paint: TextPaint,
+ @Px val heightConstraint: Float,
+) {
+ val breaker = WordBreaker(text, paint.textLocale)
+ var currentLineHeight: Float = 0f
+ val currentLineRuns: MutableList<LayoutRun> = mutableListOf()
+ val brokenLines: MutableList<LineLayout> = mutableListOf()
+
+ /**
+ * Breaks the current line and adds it to the list of broken lines.
+ *
+ * This method also resets the current line context.
+ */
+ fun breakLine() {
+ require(currentLineRuns.isNotEmpty()) { "Cannot break with empty runs." }
+ brokenLines.add(LineLayout(currentLineRuns.toList()))
+ currentLineRuns.clear()
+ currentLineHeight = 0f
+ }
+
+ private fun addRun(start: Int, end: Int, orientation: ResolvedOrientation) =
+ currentLineRuns.add(createLayoutRun(text, start, end, paint, orientation))
+
+ /**
+ * Iterate through each words within a specified range of a CharSequence.
+ *
+ * @param start The inclusive starting index of the range.
+ * @param end The exclusive ending index of the range.
+ * @param advances An array advances.
+ * @param consumer A callback function that is called for each words. It receives three
+ * parameters:
+ * - The inclusive start index of the word.
+ * - The exclusive end index of the word.
+ * - The height of the word.
+ */
+ private inline fun forEachWord(
+ start: Int,
+ end: Int,
+ orientation: ResolvedOrientation,
+ advances: FloatArray,
+ crossinline consumer: (Int, Int, Float) -> Unit
+ ) {
+ breaker.updateForRun(start, end, orientation)
+
+ var wordStart = start
+ var wordHeight = 0f
+ for (i in start until end) {
+ wordHeight += advances[i - start]
+ if (i + 1 == breaker.current) {
+ consumer(wordStart, i + 1, wordHeight)
+
+ wordStart = i + 1
+ wordHeight = 0f
+ breaker.advanceBreakOffset()
+ }
+ }
+ }
+
+ private inline fun forEachGrapheme(
+ wStart: Int,
+ wEnd: Int,
+ advances: FloatArray,
+ advancesOffset: Int,
+ crossinline consumer: (Int, Int, Float) -> Unit
+ ) {
+ var gStart = wStart
+ for (i in wStart + 1 until wEnd) {
+ if (advances[i - advancesOffset] == 0.0f) {
+ // If multiple characters are assigned to the single grapheme, the advance is
+ // assigned to the first character and remaining are zeros. Therefore, call the
+ // consumer with the first character offset and last character offset that has zero
+ // advance.
+ continue
+ }
+
+ consumer(gStart, i, advances[gStart - advancesOffset])
+ gStart = i
+ }
+ consumer(gStart, wEnd, advances[gStart - advancesOffset])
+ }
+
+ /**
+ * Processes breakable continued run.
+ *
+ * For TateChuYoko, use processTateChuYokoRun, for Ruby, use processRubyRun
+ *
+ * @param start The inclusive starting index of the run.
+ * @param end The exclusive ending index of the run.
+ * @param orientation The text orientation of the run (e.g., Horizontal, Vertical, TateChuYoko).
+ */
+ fun processRun(start: Int, end: Int, orientation: ResolvedOrientation) {
+ require(
+ orientation == ResolvedOrientation.Upright || orientation == ResolvedOrientation.Rotate
+ )
+ breaker.updateForRun(start, end, orientation)
+
+ val advances = FloatArray(end - start)
+ val layout = createLayoutRun(text, start, end, paint, orientation)
+ layout.getCharAdvances(advances, paint)
+
+ var lineStartOffset = start
+ var lastKnownGoodBreakOffset = -1
+ forEachWord(start, end, orientation, advances) { wStart, wEnd, wHeight ->
+ if (currentLineHeight + wHeight <= heightConstraint) {
+ // We still have space. Just update the line height.
+ currentLineHeight += wHeight
+ lastKnownGoodBreakOffset = wEnd
+ return@forEachWord
+ }
+
+ // Okay, the current word cannot fit the line. If we know the previous offset, break
+ // with it.
+ if (lastKnownGoodBreakOffset != -1) {
+ // If there is a good break offset before, break with it.
+ addRun(lineStartOffset, lastKnownGoodBreakOffset, orientation)
+ breakLine()
+
+ lineStartOffset = wStart
+ lastKnownGoodBreakOffset = -1
+ } else if (currentLineRuns.isNotEmpty()) {
+ // If this is the first break offset, and if some runs are already in the current
+ // line, break here (the beginning of the run) first.
+ breakLine()
+
+ lineStartOffset = wStart
+ lastKnownGoodBreakOffset = -1
+ }
+
+ // As the result of line break, if the current word can fit in the line,
+ // keep continue to the next word.
+ if (wHeight <= heightConstraint) {
+ currentLineHeight = wHeight
+ lastKnownGoodBreakOffset = wEnd
+ return@forEachWord
+ }
+
+ // Oh no, we don't have any break offset before this offset.
+ // Try to break text with desperate break.
+
+ // First, give up for TateChuYoko orientations cannot be broken further.
+ if (orientation == ResolvedOrientation.TateChuYoko) {
+ addRun(wStart, wEnd, orientation)
+ breakLine()
+ lineStartOffset = wEnd
+ return@forEachWord
+ }
+
+ forEachGrapheme(lineStartOffset, wEnd, advances, start) { gStart, gEnd, gHeight ->
+ if (gStart == lineStartOffset) {
+ // Ensure there's at least one grapheme per line during breaking.
+ currentLineHeight = gHeight
+ lastKnownGoodBreakOffset = gEnd
+ return@forEachGrapheme
+ } else if (currentLineHeight + gHeight <= heightConstraint) {
+ // We still have space. Extend the current line and continues
+ currentLineHeight += gHeight
+ lastKnownGoodBreakOffset = gEnd
+ return@forEachGrapheme
+ }
+
+ // We don't have space, so break line with the previously known good grapheme break
+ // offset.
+ addRun(lineStartOffset, lastKnownGoodBreakOffset, orientation)
+ breakLine()
+
+ // Updating the current line but ensure there's at least one grapheme per line.
+ currentLineHeight = gHeight
+ lineStartOffset = lastKnownGoodBreakOffset
+ lastKnownGoodBreakOffset = gEnd
+ }
+ }
+
+ // Add the remaining part of the run to the current line.
+ addRun(lineStartOffset, end, orientation)
+ }
+
+ /**
+ * Processes a continued text run within a layout for RubySpan.
+ *
+ * @param start The inclusive starting index of the run.
+ * @param end The exclusive ending index of the run.
+ * @param ruby The ruby span.
+ * @param orientation The text orientation mode.
+ */
+ fun processRubyRun(start: Int, end: Int, ruby: RubySpan, @OrientationMode orientation: Int) {
+ val rubyLayout = RubyLayoutRun(text, start, end, orientation, paint, ruby)
+ processNonBreakableLayout(rubyLayout)
+ }
+
+ fun processTateChuYoko(start: Int, end: Int) {
+ val layout = createLayoutRun(text, start, end, paint, ResolvedOrientation.TateChuYoko)
+ processNonBreakableLayout(layout)
+ }
+
+ private fun processNonBreakableLayout(layout: LayoutRun) {
+ val height = layout.height
+ if (currentLineHeight + height <= heightConstraint) {
+ currentLineRuns.add(layout)
+ currentLineHeight += height
+ return
+ }
+
+ if (currentLineRuns.isNotEmpty()) {
+ // The line goes over the height limit by appending this Layout.
+ // Break it before adding Ruby.
+ breakLine()
+ }
+
+ if (height <= heightConstraint) {
+ currentLineRuns.add(layout)
+ currentLineHeight = height
+ return
+ }
+
+ currentLineRuns.add(layout)
+ breakLine()
+ }
+
+ /**
+ * Finishes the line breaking process and returns the result.
+ *
+ * @return The result of the line breaking process.
+ */
+ fun finish(): Result {
+ breakLine()
+
+ // To use the same line width, iterate over all lines and use the maximum of it.
+ var leftSide = 0f
+ var rightSide = 0f
+
+ brokenLines.forEach {
+ leftSide = min(leftSide, it.leftSide)
+ rightSide = max(rightSide, it.rightSide)
+ }
+
+ return Result(brokenLines, (rightSide - leftSide) * brokenLines.size, leftSide, rightSide)
+ }
+}
diff --git a/text/text-vertical/src/main/java/androidx/text/vertical/LineLayout.kt b/text/text-vertical/src/main/java/androidx/text/vertical/LineLayout.kt
new file mode 100644
index 0000000..c26c9ed
--- /dev/null
+++ b/text/text-vertical/src/main/java/androidx/text/vertical/LineLayout.kt
@@ -0,0 +1,127 @@
+/*
+ * 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.text.TextPaint
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Creates a LineLayout.
+ *
+ * @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 textOrientation The orientation mode.
+ */
+internal fun createLineLayout(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ paint: TextPaint,
+ @OrientationMode textOrientation: Int
+) =
+ LineLayout(
+ mutableListOf<LayoutRun>().apply {
+ forEachOrientation(text, start, end, textOrientation) { runStart, runEnd, orientation ->
+ add(createLayoutRun(text, runStart, runEnd, paint, orientation))
+ }
+ }
+ )
+
+/**
+ * Represents a layout of multiple [LayoutRun]s arranged in a single line.
+ *
+ * @param runs The list of [LayoutRun]s composing this layout.
+ */
+internal class LineLayout(
+ val runs: List<LayoutRun>,
+) {
+ val start: Int
+ get() = runs.first().start
+
+ val end: Int
+ get() = runs.last().end
+
+ /**
+ * 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 [rightSide] value, i.e. width = right -
+ * left.
+ */
+ val leftSide: 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 [leftSide] from this amount, i.e. width = right - left.
+ */
+ val rightSide: Float
+
+ /**
+ * Distance from the top to bottom in pixels.
+ *
+ * This is always positive value.
+ */
+ val height: Float
+
+ /**
+ * Distance from the right to left in pixels.
+ *
+ * This is always positive value.
+ */
+ val width: Float
+ get() = rightSide - leftSide
+
+ init {
+ val (l, r, h) =
+ runs.fold(Triple(0f, 0f, 0f)) { acc, run ->
+ Triple(
+ min(acc.first, run.leftSideOffset), // leftSide
+ max(acc.second, run.rightSideOffset), // rightSide
+ acc.third + run.height // height
+ )
+ }
+ leftSide = l
+ rightSide = r
+ height = h
+ }
+
+ /**
+ * Draws the laid out text on the canvas as a single line.
+ *
+ * @param canvas The canvas to draw on.
+ * @param originX The x-coordinate of the drawing origin.
+ * @param originY The y-coordinate of the drawing origin.
+ * @param paint The paint used for text rendering.
+ */
+ fun draw(canvas: Canvas, originX: Float, originY: Float, paint: TextPaint) {
+ var y = originY
+ runs.forEach { run ->
+ run.draw(canvas, originX, y, paint)
+ y += run.height
+ }
+ }
+}
diff --git a/text/text-vertical/src/main/java/androidx/text/vertical/Orientations.kt b/text/text-vertical/src/main/java/androidx/text/vertical/Orientations.kt
new file mode 100644
index 0000000..6771e4d
--- /dev/null
+++ b/text/text-vertical/src/main/java/androidx/text/vertical/Orientations.kt
@@ -0,0 +1,253 @@
+/*
+ * 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.icu.lang.UCharacter
+import android.icu.lang.UCharacter.VerticalOrientation
+import android.icu.lang.UProperty
+import android.text.Spanned
+import androidx.annotation.IntDef
+
+/**
+ * Represents the orientation of text within a vertical writing mode.
+ *
+ * This controls how text characters are displayed when using a vertical writing mode. For more
+ * information, refer to:
+ * [CSS text-orientation](https://www.w3.org/TR/css-writing-modes-3/#text-orientation)
+ */
+public object TextOrientation {
+ /**
+ * Characters from horizontal scripts are rotated 90 degrees clockwise, while characters from
+ * vertical scripts remain in their original orientation.
+ *
+ * This is useful for mixed-script content where you want horizontal text to fit vertically
+ * while vertical text remains upright.
+ *
+ * Corresponds to CSS `text-orientation: mixed;`.
+ */
+ public const val MIXED: Int = 0
+
+ /**
+ * A value for the text orientation that represents the text will be laid out with the original
+ * orientations.
+ */
+ /**
+ * All characters are laid out in upright orientation, regardless of script.
+ *
+ * This is useful when you want all characters to remain upright even for horizontal scripts.
+ *
+ * Corresponds to CSS `text-orientation: upright;`.
+ */
+ public const val UPRIGHT: Int = 1
+
+ /**
+ * All characters are rotated 90 degrees clockwise, regardless of script.
+ *
+ * This is useful when you want all text to be sideways in a vertical layout.
+ *
+ * Corresponds to CSS `text-orientation: sideways;`.
+ */
+ public const val SIDEWAYS: Int = 2
+}
+
+@IntDef(value = [TextOrientation.MIXED, TextOrientation.UPRIGHT, TextOrientation.SIDEWAYS])
+internal annotation class OrientationMode
+
+/**
+ * A sealed interface representing text orientation spans for use within a vertical text layout.
+ *
+ * These spans allow for overriding the default text orientation of portions of text, such as
+ * setting it to upright, sideways, or combining text horizontally within a vertical flow
+ * (tate-chu-yoko).
+ *
+ * These spans are intended for use with [VerticalTextLayout].
+ */
+public sealed interface TextOrientationSpan {
+ /**
+ * A span that forces the enclosed text to be displayed in an upright orientation
+ * ([TextOrientation.UPRIGHT]) within a vertical text layout.
+ *
+ * This is useful for ensuring that text remains vertical, even if the surrounding text flow is
+ * sideways.
+ *
+ * @see TextOrientation.UPRIGHT
+ * @see VerticalTextLayout
+ */
+ public class Upright : TextOrientationSpan
+
+ /**
+ * A span that forces the enclosed text to be displayed in a sideways orientation
+ * ([TextOrientation.SIDEWAYS]) within a vertical text layout.
+ *
+ * This is useful for orienting text horizontally when the surrounding text is vertical.
+ *
+ * @see TextOrientation.SIDEWAYS
+ * @see VerticalTextLayout
+ */
+ public class Sideways : TextOrientationSpan
+
+ /**
+ * A span that combines a small sequence of characters (typically 2-4 digits) into a single
+ * horizontal block within a vertical text flow.
+ *
+ * This is known as "tate-chu-yoko" in Japanese typography.
+ *
+ * @see VerticalTextLayout
+ */
+ public class TextCombineUpright() : TextOrientationSpan
+}
+
+/** Represents the resolved orientation of a run of text. */
+internal enum class ResolvedOrientation {
+ Upright,
+ Rotate,
+ TateChuYoko
+}
+
+/**
+ * `RunMerger` is a utility class designed to identify and merge consecutive runs of characters with
+ * the same orientation.
+ */
+private class RunMerger(val end: Int, val consumer: (Int, Int, ResolvedOrientation) -> Unit) {
+ private var prevStart = -1
+ private var prevOrientation = ResolvedOrientation.Upright
+
+ /**
+ * Appends a new segment to the current run.
+ *
+ * @param start The inclusive starting position of the segment to be appended.
+ * @param orientation The orientation of the current segment.
+ */
+ fun append(start: Int, orientation: ResolvedOrientation) {
+ if (prevStart == -1) {
+ prevStart = start
+ prevOrientation = orientation
+ return
+ } else if (
+ prevOrientation != orientation || orientation == ResolvedOrientation.TateChuYoko
+ ) {
+ // orientation transition point. callback and update the state
+ consumer(prevStart, start, prevOrientation)
+ prevStart = start
+ prevOrientation = orientation
+ } else {
+ // do nothing. keep extending the current run.
+ }
+ }
+
+ /** Finalize the merge and callback the last run. Do not call this method multiple times. */
+ fun finish() {
+ if (prevStart != -1) {
+ consumer(prevStart, end, prevOrientation)
+ }
+ }
+}
+
+/**
+ * Iterates over characters and resolves the each character's orientation property along with the
+ * attached `OrientationSpan`.
+ *
+ * @param text The CharSequence
+ * @param start The inclusive starting index
+ * @param end The exclusive ending index
+ * @param textOrientation The text orientation mode.
+ * @param consumer A callback function that is called for each orientation transition. It receives
+ * three parameters:
+ * - The inclusive start index of the orientation run.
+ * - The exclusive end index of the orientation run.
+ * - The orientation of the range.
+ */
+internal fun forEachOrientation(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ @OrientationMode textOrientation: Int,
+ consumer: (Int, Int, ResolvedOrientation) -> Unit
+) {
+ if (start >= end) {
+ return
+ }
+
+ if (text !is Spanned) {
+ // Easy case, no spans are attached.
+ forOrientationNoSpans(text, start, end, textOrientation, consumer)
+ return
+ }
+
+ val merger = RunMerger(end, consumer)
+ forEachSpan<TextOrientationSpan>(text, start, end) { runStart, runEnd, spans ->
+ if (spans.isEmpty()) {
+ forOrientationNoSpans(text, runStart, runEnd, textOrientation) { oStart, _, resolved ->
+ merger.append(oStart, resolved)
+ }
+ } else {
+ // If multiple TextOrientationSpans are attached, use the last one.
+ val resolved =
+ when (spans.last()) {
+ is TextOrientationSpan.TextCombineUpright -> ResolvedOrientation.TateChuYoko
+ is TextOrientationSpan.Upright -> ResolvedOrientation.Upright
+ is TextOrientationSpan.Sideways -> ResolvedOrientation.Rotate
+ }
+ merger.append(runStart, resolved)
+ }
+ }
+ merger.finish()
+}
+
+/** Iterates over characters and resolves the each character's orientation property. */
+private inline fun forOrientationNoSpans(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ @OrientationMode textOrientation: Int,
+ crossinline consumer: (Int, Int, ResolvedOrientation) -> Unit
+) {
+ var prevProp = ResolvedOrientation.Upright // unused initial value
+ var prevStart = start
+ fotEachCodePoints(text, start, end) { i, cp ->
+ val prop = resolveOrientation(textOrientation, cp)
+ if (i == start) {
+ prevProp = prop
+ } else if (prevProp != prop) {
+ consumer(prevStart, i, prevProp)
+ prevProp = prop
+ prevStart = i
+ }
+ }
+ consumer(prevStart, end, prevProp)
+}
+
+/**
+ * Resolves the orientation of a character based on the provided text orientation and the
+ * character's code point.
+ *
+ * @param textOrientation The text orientation mode.
+ * @param cp The code point of the character.
+ * @return The resolved orientation of the character.
+ */
+private fun resolveOrientation(@OrientationMode textOrientation: Int, cp: Int) =
+ when (textOrientation) {
+ TextOrientation.UPRIGHT -> ResolvedOrientation.Upright
+ TextOrientation.SIDEWAYS -> ResolvedOrientation.Rotate
+ TextOrientation.MIXED -> {
+ when (UCharacter.getIntPropertyValue(cp, UProperty.VERTICAL_ORIENTATION)) {
+ VerticalOrientation.ROTATED -> ResolvedOrientation.Rotate
+ else -> ResolvedOrientation.Upright
+ }
+ }
+ else -> throw RuntimeException("Unknown orientation: $textOrientation")
+ }
diff --git a/text/text-vertical/src/main/java/androidx/text/vertical/Ruby.kt b/text/text-vertical/src/main/java/androidx/text/vertical/Ruby.kt
new file mode 100644
index 0000000..24b0303
--- /dev/null
+++ b/text/text-vertical/src/main/java/androidx/text/vertical/Ruby.kt
@@ -0,0 +1,200 @@
+/*
+ * 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.text.Spanned
+import android.text.TextPaint
+import kotlin.math.max
+
+/**
+ * A span used to specify ruby text for a portion of the text.
+ *
+ * Ruby text cannot be nested (i.e., ruby text cannot contain further ruby text). Ruby spans also
+ * cannot overlap each other.
+ *
+ * This span is designed for use with [VerticalTextLayout].
+ *
+ * @property text The ruby text to be displayed adjacent to the base text.
+ * @property orientation The text orientation of the ruby text. Defaults to [TextOrientation.MIXED].
+ * @property textScale The text scale ratio of the ruby text relative to the base text. Defaults to
+ * 0.5f.
+ */
+public class RubySpan
+private constructor(
+ public val text: CharSequence,
+ @OrientationMode public val orientation: Int,
+ public val textScale: Float,
+) {
+ /**
+ * Builder class for creating [RubySpan] instances.
+ *
+ * @param text The ruby text to be displayed adjacent to the base text.
+ */
+ public class Builder(private val text: CharSequence) {
+ private var _orientation: Int = TextOrientation.MIXED
+ private var _textScale: Float = 0.5f
+
+ /**
+ * Sets the text orientation for the ruby text.
+ *
+ * By default, [TextOrientation.MIXED] is used.
+ *
+ * @param orientation The text orientation to set.
+ * @return This [Builder] instance for method chaining.
+ */
+ public fun setOrientation(@OrientationMode orientation: Int): Builder = apply {
+ _orientation = orientation
+ }
+
+ /**
+ * Sets the text scale for the ruby text.
+ *
+ * By default, 0.5f is used, meaning the ruby text will be half the size of the base text.
+ *
+ * @param textScale The text scale to set.
+ * @return This [Builder] instance for method chaining.
+ */
+ public fun setTextScale(textScale: Float): Builder = apply { _textScale = textScale }
+
+ /**
+ * Builds and returns a new [RubySpan] instance.
+ *
+ * @return A new [RubySpan] instance.
+ */
+ public fun build(): RubySpan = RubySpan(text, _orientation, _textScale)
+ }
+}
+
+/**
+ * Iterates through each RubySpan within a specified range of a CharSequence.
+ *
+ * @param text The CharSequence
+ * @param start The inclusive starting index
+ * @param end The exclusive ending index
+ * @param consumer A callback function that is called for each RubySpan transition. It receives
+ * three parameters:
+ * - The inclusive start index of the RubySpan.
+ * - The exclusive end index of the RubySpan.
+ * - The `RubySpan` object itself, or `null` if no RubySpan is found.
+ */
+internal inline fun forEachRubySpanTransition(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ crossinline consumer: (Int, Int, RubySpan?) -> Unit
+) =
+ forEachSpan(text, start, end) { rStart, rEnd, rubySpans ->
+ require(rubySpans.size <= 1) { "RubySpan cannot be overlapped" }
+ consumer(rStart, rEnd, rubySpans.getOrNull(0))
+ }
+
+/**
+ * A special LayoutRun specialized for a Ruby text.
+ *
+ * @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 textOrientation The text orientation mode.
+ * @param paint The paint used for text rendering.
+ * @param rubySpan The rubySpan attached to the range.
+ */
+internal class RubyLayoutRun(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ @OrientationMode textOrientation: Int,
+ paint: TextPaint,
+ rubySpan: RubySpan,
+) : LayoutRun(text, start, end) {
+
+ init {
+ val rubyText = rubySpan.text
+ if (rubyText is Spanned) {
+ require(rubyText.getSpans(0, rubyText.length, RubySpan::class.java).isEmpty()) {
+ "Ruby text cannot have RubySpan. (Ruby cannot be nested.)"
+ }
+ }
+ }
+
+ override val height: Float
+ get() = max(bodyLayoutRuns.height, rubyLayoutRuns.height)
+
+ override val leftSideOffset: Float
+ get() = bodyLayoutRuns.leftSide
+
+ override val rightSideOffset: Float
+ get() = bodyLayoutRuns.rightSide + rubyLayoutRuns.width
+
+ private val rubyScale = rubySpan.textScale
+ private val rubyLayoutRuns: LineLayout =
+ withTempScale(paint, rubyScale) {
+ createLineLayout(rubySpan.text, 0, rubySpan.text.length, paint, rubySpan.orientation)
+ }
+ private val bodyLayoutRuns: LineLayout =
+ createLineLayout(text, start, end, paint, textOrientation)
+
+ override fun draw(canvas: Canvas, originX: Float, originY: Float, paint: TextPaint) {
+ val bodyHeight = bodyLayoutRuns.height
+ val rubyHeight = rubyLayoutRuns.height
+
+ // Vertical centering the body text and ruby text.
+ var bodyY = originY
+ var rubyY = originY
+ val heightDiffHalf = (bodyHeight - rubyHeight) / 2
+ if (heightDiffHalf > 0) {
+ // The body text is taller than the ruby text. Push the ruby text for centering.
+ rubyY += heightDiffHalf
+ } else {
+ // The body text is shorter than the ruby text. Push the body text for centering.
+ bodyY -= heightDiffHalf
+ }
+
+ bodyLayoutRuns.draw(canvas, originX, bodyY, paint)
+
+ val rubyX = originX + bodyLayoutRuns.rightSide - rubyLayoutRuns.leftSide
+ withTempScale(paint, rubyScale) { rubyLayoutRuns.draw(canvas, rubyX, rubyY, paint) }
+ }
+
+ override fun getCharAdvances(out: FloatArray, paint: TextPaint) {
+ // We don't support line break inside Ruby. Just assigning all height into the first
+ // character for preventing line break.
+ out[0] = height
+ if (out.size > 1) {
+ out.fill(0f, 1, out.size)
+ }
+ }
+}
+
+/**
+ * Executes a block of code with a temporary applying scaling of the text size of a given
+ * [TextPaint].
+ */
+private inline fun <T : Paint, R> withTempScale(
+ textPaint: T,
+ scale: Float,
+ crossinline block: () -> R
+): R {
+ val originalSize = textPaint.textSize
+ textPaint.textSize *= scale
+ try {
+ return block()
+ } finally {
+ textPaint.textSize = originalSize
+ }
+}
diff --git a/text/text-vertical/src/main/java/androidx/text/vertical/TextUtils.kt b/text/text-vertical/src/main/java/androidx/text/vertical/TextUtils.kt
new file mode 100644
index 0000000..a2845e2
--- /dev/null
+++ b/text/text-vertical/src/main/java/androidx/text/vertical/TextUtils.kt
@@ -0,0 +1,102 @@
+/*
+ * 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.text.Spanned
+
+/**
+ * Iterates through spans of a specific type within a given range of a CharSequence.
+ *
+ * @param text The CharSequence
+ * @param start The inclusive starting index.
+ * @param end The exclusive ending index.
+ * @param consumer A lambda function that will be called for each segment containing spans.
+ */
+internal inline fun <reified T> forEachSpan(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ crossinline consumer: (Int, Int, Array<T>) -> Unit
+) {
+ if (text !is Spanned) {
+ consumer(start, end, arrayOf())
+ return
+ }
+
+ var spanI = start
+ while (spanI < end) {
+ val next = text.nextSpanTransition(spanI, end, T::class.java)
+ val spans = text.getSpans(spanI, next, T::class.java)
+ consumer(spanI, next, spans)
+ spanI = next
+ }
+}
+
+/**
+ * Iterates over the code points within a specified range of a [CharSequence].
+ *
+ * @param text The CharSequence
+ * @param start The inclusive starting index.
+ * @param end The exclusive ending index.
+ * @param consumer A lambda function that will be called for each code points.
+ */
+internal inline fun fotEachCodePoints(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ crossinline consumer: (Int, Int) -> Unit
+) {
+ var i = start
+ while (i < end) {
+ val cp = Character.codePointAt(text, i)
+ consumer(i, cp)
+ i += Character.charCount(cp)
+ }
+}
+
+/**
+ * Extension function to iterate over paragraphs within a CharSequence.
+ *
+ * This function iterates through the specified range of the CharSequence, identifying paragraphs
+ * based on newline characters ('\n'). For each paragraph found, it invokes the provided block with
+ * the start and end indices of the paragraph.
+ *
+ * @param start The inclusive starting index.
+ * @param end The exclusive ending index.
+ * @param block A lambda function that takes two Int parameters representing the start and end
+ * indices of a paragraph.
+ */
+internal inline fun CharSequence.forEachParagraph(
+ start: Int,
+ end: Int,
+ crossinline block: (Int, Int) -> Unit
+) {
+ var paraStart = start
+ while (paraStart < end) {
+ var paraEnd = indexOf('\n', paraStart) // TODO support other paragraph separator
+ if (paraEnd == -1 || paraEnd >= end) {
+ paraEnd = end
+ } else {
+ // Include the paragraph separator in the current paragraph.
+ paraEnd++
+ }
+
+ block(paraStart, paraEnd)
+
+ paraStart = paraEnd
+ }
+}
diff --git a/text/text-vertical/src/main/java/androidx/text/vertical/VerticalTextLayout.kt b/text/text-vertical/src/main/java/androidx/text/vertical/VerticalTextLayout.kt
new file mode 100644
index 0000000..a6602f3
--- /dev/null
+++ b/text/text-vertical/src/main/java/androidx/text/vertical/VerticalTextLayout.kt
@@ -0,0 +1,99 @@
+/*
+ * 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.text.TextPaint
+import androidx.annotation.Px
+
+/**
+ * Represents the result of laying out text vertically.
+ *
+ * This class encapsulates the result of a vertical text layout process. It stores the layout's
+ * properties and provides methods to draw the layout on a [Canvas].
+ *
+ * @property orientation The text orientation used for building this vertical layout.
+ * @property paint The [TextPaint] used for building this vertical layout. Do not mutate this paint
+ * instance.
+ */
+public class VerticalTextLayout
+private constructor(
+ public val text: CharSequence,
+ public val start: Int,
+ public val end: Int,
+ public val paint: TextPaint,
+ @Px public val height: Float,
+ @OrientationMode public val orientation: Int,
+ private val result: LineBreaker.Result
+) {
+ /** The width constraint of the vertical text in pixels. */
+ @get:Px
+ public val width: Float
+ get() = result.width
+
+ /**
+ * Draws this text layout onto the specified [Canvas].
+ *
+ * @param canvas The [Canvas] to draw onto.
+ * @param x The horizontal offset in pixels. The drawing origin is the top-right corner.
+ * @param y The vertical offset in pixels. The drawing origin is the top-right corner.
+ */
+ public fun draw(canvas: Canvas, @Px x: Float, @Px y: Float) {
+ result.draw(canvas, x, y, paint)
+ }
+
+ /**
+ * Builder class for creating instances of [VerticalTextLayout].
+ *
+ * @param text The text to be laid out.
+ * @param start The inclusive start offset of the target text range.
+ * @param end The exclusive end offset of the target text range.
+ * @param paint The [TextPaint] instance used for laying out the text.
+ * @param height The height constraint in pixels.
+ */
+ public class Builder(
+ private val text: CharSequence,
+ private val start: Int,
+ private val end: Int,
+ private val paint: TextPaint,
+ @Px private val height: Float
+ ) {
+ private var _orientation: Int = TextOrientation.MIXED
+
+ /**
+ * Sets the text orientation.
+ *
+ * Defaults to [TextOrientation.MIXED].
+ *
+ * @param orientation The desired text orientation.
+ * @return This [Builder] instance for chaining.
+ */
+ public fun setOrientation(@OrientationMode orientation: Int): Builder = apply {
+ _orientation = orientation
+ }
+
+ /**
+ * Builds the [VerticalTextLayout] instance.
+ *
+ * @return The constructed [VerticalTextLayout].
+ */
+ public fun build(): VerticalTextLayout {
+ val lines = LineBreaker.breakTextIntoLines(text, start, end, paint, height)
+ return VerticalTextLayout(text, start, end, paint, height, _orientation, lines)
+ }
+ }
+}
diff --git a/text/text-vertical/testapp/build.gradle b/text/text-vertical/testapp/build.gradle
new file mode 100644
index 0000000..37e61d9
--- /dev/null
+++ b/text/text-vertical/testapp/build.gradle
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 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.
+ */
+import androidx.build.SoftwareType
+
+plugins {
+ id("AndroidXPlugin")
+ id("AndroidXComposePlugin")
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+}
+
+dependencies {
+ api(libs.kotlinStdlib)
+ implementation(project(":text:text-vertical"))
+
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.6.4")
+ api("androidx.appcompat:appcompat:1.6.1")
+ api("androidx.activity:activity-compose:1.8.2")
+ implementation("androidx.compose.material3:material3:1.2.1")
+
+ api("androidx.compose.ui:ui:1.7.1")
+ api("androidx.compose.ui:ui-graphics:1.7.1")
+ api("androidx.compose.ui:ui-util:1.7.1")
+ api("androidx.compose.foundation:foundation:1.7.1")
+}
+
+androidx {
+ name = "Vertical Text TestApp"
+ type = SoftwareType.TEST_APPLICATION
+ inceptionYear = "2025"
+ description = "Contains the demo code for the vertical writing"
+}
+
+android {
+ namespace = "androidx.text.vertical.testapp"
+ compileSdk = 36
+ defaultConfig {
+ targetSdk = 36
+ minSdk = 36
+ }
+}
diff --git a/text/text-vertical/testapp/src/main/AndroidManifest.xml b/text/text-vertical/testapp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..dcff015
--- /dev/null
+++ b/text/text-vertical/testapp/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ 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.
+ -->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <uses-sdk android:minSdkVersion="36"/>
+ <application
+ android:label="Vertical Text Demo">
+ <activity android:name=".VerticalTextSampleActivity" android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <category android:name="android.intent.category.LAUNCHER"/>
+ </intent-filter>
+ </activity>
+ </application>
+</manifest>
diff --git a/text/text-vertical/testapp/src/main/java/androidx/text/vertical/testapp/VerticalTextSampleActivity.kt b/text/text-vertical/testapp/src/main/java/androidx/text/vertical/testapp/VerticalTextSampleActivity.kt
new file mode 100644
index 0000000..5b50d30
--- /dev/null
+++ b/text/text-vertical/testapp/src/main/java/androidx/text/vertical/testapp/VerticalTextSampleActivity.kt
@@ -0,0 +1,242 @@
+/*
+ * 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.testapp
+
+import android.graphics.Typeface
+import android.os.Bundle
+import android.text.Spanned
+import android.text.TextPaint
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.PrimaryTabRow
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.em
+import androidx.compose.ui.unit.sp
+import androidx.text.vertical.VerticalTextLayout
+import java.util.Locale
+import kotlin.math.max
+
+class VerticalTextSampleActivity : ComponentActivity() {
+ @OptIn(ExperimentalMaterial3Api::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ val demos =
+ arrayOf<Pair<String, @Composable () -> Unit>>(
+ "Long Text" to { ZoomableVerticalText { LongText(it) } },
+ "Complex Text" to { ZoomableVerticalText { ComplexText(it) } },
+ )
+
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Column(modifier = Modifier.padding(innerPadding)) {
+ var selectedTabIndex by remember { mutableIntStateOf(0) }
+ PrimaryTabRow(selectedTabIndex = 0, modifier = Modifier.fillMaxWidth()) {
+ demos.forEachIndexed { index, (title, _) ->
+ Tab(
+ selected = selectedTabIndex == index,
+ onClick = { selectedTabIndex = index },
+ text = { Text(title) }
+ )
+ }
+ }
+ demos[selectedTabIndex].second()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ZoomableVerticalText(content: @Composable (TextPaint) -> Unit) {
+ val fontSize = with(LocalDensity.current) { 32.sp.toPx() }
+ var zoom by remember { mutableFloatStateOf(1f) }
+ var offsetX by remember { mutableFloatStateOf(0f) }
+ val paint =
+ remember(zoom) {
+ TextPaint().apply {
+ textSize = fontSize * zoom
+ typeface = Typeface.SERIF
+ textLocale =
+ Locale.Builder()
+ .setLocale(Locale.JAPANESE)
+ .setUnicodeLocaleKeyword("lb", "strict")
+ .build()
+ }
+ }
+
+ Box(
+ modifier =
+ Modifier.pointerInput(Unit) {
+ detectTapGestures(
+ onDoubleTap = {
+ zoom = 1f
+ offsetX = 0f
+ }
+ )
+ }
+ .pointerInput(Unit) {
+ detectTransformGestures { _, offsetChange, gestureZoom, _ ->
+ zoom = zoom * gestureZoom
+ offsetX = max(0f, offsetX + offsetChange.x)
+ }
+ }
+ .graphicsLayer(translationX = offsetX)
+ ) {
+ content(paint)
+ }
+}
+
+@Composable
+fun VerticalText(
+ text: Spanned,
+ paint: TextPaint,
+ modifier: Modifier = Modifier,
+) {
+ var vTextLayout by remember { mutableStateOf<VerticalTextLayout?>(null) }
+ Layout(
+ modifier =
+ modifier.fillMaxSize().drawWithContent {
+ drawIntoCanvas { c ->
+ vTextLayout?.draw(c.nativeCanvas, c.nativeCanvas.width.toFloat(), 0f)
+ }
+ },
+ content = {},
+ ) { _, constraints ->
+ vTextLayout =
+ VerticalTextLayout.Builder(
+ text = text,
+ start = 0,
+ end = text.length,
+ paint = paint,
+ height = constraints.maxHeight.toFloat()
+ )
+ .build()
+ layout(constraints.maxWidth, constraints.maxHeight) {}
+ }
+}
+
+@Composable
+fun LongText(paint: TextPaint, modifier: Modifier = Modifier) {
+ VerticalText(
+ buildVerticalText {
+ text("吾輩は猫である。", mapOf("吾輩" to "わがはい", "猫" to "ねこ"))
+ text("名前はまだ無い。", mapOf("名前" to "なまえ", "無" to "な"))
+ text("\n")
+ text("どこで生まれたかとんと見当がつかぬ。", mapOf("見当" to "けんとう"))
+ text("何でも薄暗いじめじめしたところでニャーニャー泣いていた事だけは記憶している。")
+ text("吾輩はここで始めて人間というものを見た。")
+ text("しかもあとで聞くとそれは書生という人間中で一番獰悪な種族であったそうだ。", mapOf("獰悪" to "どうあく"))
+ text("この書生というのは時々我々を捕えて煮て食うという話である。", mapOf("捕" to "つかま", "煮" to "に"))
+ text("しかしその当時は何という考もなかったから別段恐しいとも思わなかった。")
+ text("ただ彼の掌に載せられてスーと持ち上げられた時何だかフワフワした感じがあったばかりである。", mapOf("掌" to "てのひら"))
+ text("掌の上で少し落ちついて書生の顔を見たのがいわゆる人間というものの見始であろう。", mapOf("見始" to "みはじめ"))
+ text("この時妙なものだと思った感じが今でも残っている。")
+ text("第一毛をもって装飾されべきはずの顔がつるつるしてまるで薬缶だ。", mapOf("薬缶" to "やかん"))
+ text("その後猫にもだいぶ逢ったがこんな片輪には一度も出会わした事がない。", mapOf("片端" to "かたわ", "出会" to "でく"))
+ text("のみならず顔の真中があまりに突起している。")
+ text("そうしてその穴の中から時々ぷうぷうと煙を吹く。", mapOf("煙" to "けむり"))
+ text("どうも咽せぽくて実に弱った。", mapOf("咽" to "む"))
+ text("これが人間の飲む煙草というものである事はようやくこの頃知った。", mapOf("煙草" to "たばこ"))
+ text("\n")
+ },
+ paint,
+ modifier
+ )
+}
+
+@Composable
+fun ComplexText(paint: TextPaint, modifier: Modifier = Modifier) {
+ VerticalText(
+ buildVerticalText {
+ Upright("2024")
+ text("年の")
+ ruby("クリスマス") {
+ TateChuYoko("12")
+ text("月")
+ TateChuYoko("25")
+ text("日")
+ }
+ text("に")
+ Sideways("Google Pixel")
+ text("を買う。\n")
+
+ Upright("2024")
+ text("年は")
+ TateChuYoko("2024")
+ text("年ともかけるし")
+ Sideways("2024年")
+ text("ともかけるよ。\n")
+
+ text("もちろん")
+ withStyle(textColor = Color.Red) {
+ ruby(
+ buildVerticalText {
+ text("インライン")
+ withStyle(fontSize = 1.5.em) { text("スタイリング") }
+ }
+ ) {
+ withStyle(fontSize = 0.8.em) { Sideways("inline ") }
+ withStyle(backgroundColor = Color.Green) { Sideways("styling") }
+ }
+ withStyle(backgroundColor = Color.LightGray) {
+ text("も")
+ withStyle(fontSize = 2.em) { text("可能") }
+ text("です。\n")
+ }
+ }
+
+ TateChuYoko(
+ buildVerticalText { // Tate Chu Yoko only respect styling.
+ text("2")
+ withStyle(backgroundColor = Color.Red) { text("0") }
+ withStyle(backgroundColor = Color.Green) { text("2") }
+ text("5")
+ }
+ )
+ text("年もよろしくお願いいたします。")
+ },
+ paint,
+ modifier
+ )
+}
diff --git a/text/text-vertical/testapp/src/main/java/androidx/text/vertical/testapp/VerticalTextUtils.kt b/text/text-vertical/testapp/src/main/java/androidx/text/vertical/testapp/VerticalTextUtils.kt
new file mode 100644
index 0000000..3d86958d
--- /dev/null
+++ b/text/text-vertical/testapp/src/main/java/androidx/text/vertical/testapp/VerticalTextUtils.kt
@@ -0,0 +1,117 @@
+/*
+ * 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.testapp
+
+import android.text.SpannableStringBuilder
+import android.text.Spanned
+import android.text.TextPaint
+import android.text.style.MetricAffectingSpan
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.isSpecified
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.isSpecified
+import androidx.text.vertical.RubySpan
+import androidx.text.vertical.TextOrientationSpan
+
+const val SPAN_FLAG = Spanned.SPAN_INCLUSIVE_EXCLUSIVE
+
+class VerticalTextBuilder {
+ @Composable
+ fun Sideways(text: CharSequence) = withSpan(TextOrientationSpan.Sideways(), { this.text(text) })
+
+ @Composable
+ fun Upright(text: CharSequence) = withSpan(TextOrientationSpan.Upright(), { this.text(text) })
+
+ @Composable
+ fun TateChuYoko(text: CharSequence) =
+ withSpan(TextOrientationSpan.TextCombineUpright(), { this.text(text) })
+
+ @Composable
+ fun <R : Any> ruby(ruby: CharSequence, block: @Composable VerticalTextBuilder.() -> R): R =
+ withSpan(RubySpan.Builder(ruby).build(), block)
+
+ fun text(text: CharSequence, rubyMap: Map<String, String> = emptyMap()) {
+ val textStartOffset = result.length
+ result.append(text)
+
+ rubyMap.forEach { key, ruby ->
+ var searchOffset = textStartOffset
+ var found = result.indexOf(key, searchOffset)
+ while (found != -1) {
+ result.setSpan(RubySpan.Builder(ruby).build(), found, found + key.length, SPAN_FLAG)
+ searchOffset = found + key.length
+ found = result.indexOf(key, searchOffset)
+ }
+ }
+ }
+
+ @Composable
+ private fun <R : Any> withSpan(span: Any, block: @Composable VerticalTextBuilder.() -> R): R {
+ val index = result.length
+ val r = block(this)
+ result.setSpan(span, index, result.length, SPAN_FLAG)
+ return r
+ }
+
+ private class TextStyleSpan(
+ private val fontSize: TextUnit = TextUnit.Unspecified,
+ private val textColor: Color = Color.Unspecified,
+ private val backgroundColor: Color = Color.Unspecified,
+ private val density: Density,
+ ) : MetricAffectingSpan() {
+
+ override fun updateMeasureState(textPaint: TextPaint) {
+ if (fontSize.isSpecified) {
+ if (fontSize.isSp) {
+ textPaint.textSize = fontSize.value * density.fontScale * density.density
+ } else {
+ textPaint.textSize *= fontSize.value
+ }
+ }
+ if (textColor.isSpecified) {
+ textPaint.color = textColor.toArgb()
+ }
+ if (backgroundColor.isSpecified) {
+ textPaint.bgColor = backgroundColor.toArgb()
+ }
+ }
+
+ override fun updateDrawState(tp: TextPaint) = updateMeasureState(tp)
+ }
+
+ @Composable
+ fun <R : Any> withStyle(
+ fontSize: TextUnit = TextUnit.Unspecified,
+ textColor: Color = Color.Unspecified,
+ backgroundColor: Color = Color.Unspecified,
+ block: @Composable VerticalTextBuilder.() -> R
+ ): R =
+ withSpan(
+ TextStyleSpan(fontSize, textColor, backgroundColor, LocalDensity.current),
+ block,
+ )
+
+ var result = SpannableStringBuilder()
+}
+
+@Composable
+fun buildVerticalText(builder: @Composable VerticalTextBuilder.() -> Unit): SpannableStringBuilder =
+ VerticalTextBuilder().apply { this.builder() }.result