Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 99893a1e authored by Seigo Nonaka's avatar Seigo Nonaka
Browse files

Add and implement inter character justification

Bug: 283193133
Test: atest CtsTextTestCases:android.text.cts.LayoutInterJustificationTest
Test: atest CtsTextTestCases CtsGraphicsTestCases
Change-Id: I8b1afb1ab1f7b6a48c6c22ec69edf8697311990e
parent d2a260e5
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -17874,6 +17874,7 @@ package android.graphics.text {
    field public static final int HYPHENATION_FREQUENCY_FULL = 2; // 0x2
    field public static final int HYPHENATION_FREQUENCY_NONE = 0; // 0x0
    field public static final int HYPHENATION_FREQUENCY_NORMAL = 1; // 0x1
    field @FlaggedApi("com.android.text.flags.inter_character_justification") public static final int JUSTIFICATION_MODE_INTER_CHARACTER = 2; // 0x2
    field public static final int JUSTIFICATION_MODE_INTER_WORD = 1; // 0x1
    field public static final int JUSTIFICATION_MODE_NONE = 0; // 0x0
  }
@@ -46946,6 +46947,7 @@ package android.text {
    field @NonNull public static final android.text.Layout.TextInclusionStrategy INCLUSION_STRATEGY_ANY_OVERLAP;
    field @NonNull public static final android.text.Layout.TextInclusionStrategy INCLUSION_STRATEGY_CONTAINS_ALL;
    field @NonNull public static final android.text.Layout.TextInclusionStrategy INCLUSION_STRATEGY_CONTAINS_CENTER;
    field @FlaggedApi("com.android.text.flags.inter_character_justification") public static final int JUSTIFICATION_MODE_INTER_CHARACTER = 2; // 0x2
    field public static final int JUSTIFICATION_MODE_INTER_WORD = 1; // 0x1
    field public static final int JUSTIFICATION_MODE_NONE = 0; // 0x0
  }
+13 −5
Original line number Diff line number Diff line
@@ -152,7 +152,8 @@ public abstract class Layout {
    /** @hide */
    @IntDef(prefix = { "JUSTIFICATION_MODE_" }, value = {
            LineBreaker.JUSTIFICATION_MODE_NONE,
            LineBreaker.JUSTIFICATION_MODE_INTER_WORD
            LineBreaker.JUSTIFICATION_MODE_INTER_WORD,
            LineBreaker.JUSTIFICATION_MODE_INTER_CHARACTER,
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface JustificationMode {}
@@ -168,6 +169,13 @@ public abstract class Layout {
    public static final int JUSTIFICATION_MODE_INTER_WORD =
            LineBreaker.JUSTIFICATION_MODE_INTER_WORD;

    /**
     * Value for justification mode indicating the text is justified by stretching letter spacing.
     */
    @FlaggedApi(FLAG_INTER_CHARACTER_JUSTIFICATION)
    public static final int JUSTIFICATION_MODE_INTER_CHARACTER =
            LineBreaker.JUSTIFICATION_MODE_INTER_CHARACTER;

    /*
     * Line spacing multiplier for default line spacing.
     */
@@ -809,7 +817,7 @@ public abstract class Layout {
                        getEllipsisStart(lineNum) + getEllipsisCount(lineNum),
                        isFallbackLineSpacingEnabled());
                if (justify) {
                    tl.justify(right - left - indentWidth);
                    tl.justify(mJustificationMode, right - left - indentWidth);
                }
                tl.draw(canvas, x, ltop, lbaseline, lbottom);
            }
@@ -1058,7 +1066,7 @@ public abstract class Layout {
                    getEllipsisStart(line), getEllipsisStart(line) + getEllipsisCount(line),
                    isFallbackLineSpacingEnabled());
            if (isJustificationRequired(line)) {
                tl.justify(getJustifyWidth(line));
                tl.justify(mJustificationMode, getJustifyWidth(line));
            }
            tl.metrics(null, rectF, false, null);

@@ -1794,7 +1802,7 @@ public abstract class Layout {
                getEllipsisStart(line), getEllipsisStart(line) + getEllipsisCount(line),
                isFallbackLineSpacingEnabled());
        if (isJustificationRequired(line)) {
            tl.justify(getJustifyWidth(line));
            tl.justify(mJustificationMode, getJustifyWidth(line));
        }
        final float width = tl.metrics(null, null, mUseBoundsForWidth, null);
        TextLine.recycle(tl);
@@ -1882,7 +1890,7 @@ public abstract class Layout {
                getEllipsisStart(line), getEllipsisStart(line) + getEllipsisCount(line),
                isFallbackLineSpacingEnabled());
        if (isJustificationRequired(line)) {
            tl.justify(getJustifyWidth(line));
            tl.justify(mJustificationMode, getJustifyWidth(line));
        }
        final float width = tl.metrics(null, null, mUseBoundsForWidth, null);
        TextLine.recycle(tl);
+56 −12
Original line number Diff line number Diff line
@@ -100,9 +100,25 @@ public class TextLine {

    // Additional width of whitespace for justification. This value is per whitespace, thus
    // the line width will increase by mAddedWidthForJustify x (number of stretchable whitespaces).
    private float mAddedWidthForJustify;
    private float mAddedWordSpacingInPx;
    private float mAddedLetterSpacingInPx;
    private boolean mIsJustifying;

    @VisibleForTesting
    public float getAddedWordSpacingInPx() {
        return mAddedWordSpacingInPx;
    }

    @VisibleForTesting
    public float getAddedLetterSpacingInPx() {
        return mAddedLetterSpacingInPx;
    }

    @VisibleForTesting
    public boolean isJustifying() {
        return mIsJustifying;
    }

    private final TextPaint mWorkPaint = new TextPaint();
    private final TextPaint mActivePaint = new TextPaint();
    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
@@ -259,7 +275,7 @@ public class TextLine {
            }
        }
        mTabs = tabStops;
        mAddedWidthForJustify = 0;
        mAddedWordSpacingInPx = 0;
        mIsJustifying = false;

        mEllipsisStart = ellipsisStart != ellipsisEnd ? ellipsisStart : 0;
@@ -274,19 +290,42 @@ public class TextLine {
     * Justify the line to the given width.
     */
    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
    public void justify(float justifyWidth) {
    public void justify(@Layout.JustificationMode int justificationMode, float justifyWidth) {
        int end = mLen;
        while (end > 0 && isLineEndSpace(mText.charAt(mStart + end - 1))) {
            end--;
        }
        if (justificationMode == Layout.JUSTIFICATION_MODE_INTER_WORD) {
            float width = Math.abs(measure(end, false, null, null, null));
            final int spaces = countStretchableSpaces(0, end);
            if (spaces == 0) {
                // There are no stretchable spaces, so we can't help the justification by adding any
                // width.
                return;
            }
        final float width = Math.abs(measure(end, false, null, null, null));
        mAddedWidthForJustify = (justifyWidth - width) / spaces;
            mAddedWordSpacingInPx = (justifyWidth - width) / spaces;
            mAddedLetterSpacingInPx = 0;
        } else {  // justificationMode == Layout.JUSTIFICATION_MODE_INTER_CHARACTER
            LineInfo lineInfo = new LineInfo();
            float width = Math.abs(measure(end, false, null, null, lineInfo));

            int lettersCount = lineInfo.getClusterCount();
            if (lettersCount < 2) {
                return;
            }
            mAddedLetterSpacingInPx = (justifyWidth - width) / (lettersCount - 1);
            if (mAddedLetterSpacingInPx > 0.03) {
                // If the letter spacing is more than 0.03em, the ligatures are automatically
                // disabled, so re-calculate everything without ligatures.
                final String oldFontFeatures = mPaint.getFontFeatureSettings();
                mPaint.setFontFeatureSettings(oldFontFeatures + ", \"liga\" off, \"cliga\" off");
                width = Math.abs(measure(end, false, null, null, lineInfo));
                lettersCount = lineInfo.getClusterCount();
                mAddedLetterSpacingInPx = (justifyWidth - width) / (lettersCount - 1);
                mPaint.setFontFeatureSettings(oldFontFeatures);
            }
            mAddedWordSpacingInPx = 0;
        }
        mIsJustifying = true;
    }

@@ -529,6 +568,9 @@ public class TextLine {
            throw new IndexOutOfBoundsException(
                    "offset(" + offset + ") should be less than line limit(" + mLen + ")");
        }
        if (lineInfo != null) {
            lineInfo.setClusterCount(0);
        }
        final int target = trailing ? offset - 1 : offset;
        if (target < 0) {
            return 0;
@@ -1076,7 +1118,8 @@ public class TextLine {
        TextPaint wp = mWorkPaint;
        wp.set(mPaint);
        if (mIsJustifying) {
            wp.setWordSpacing(mAddedWidthForJustify);
            wp.setWordSpacing(mAddedWordSpacingInPx);
            wp.setLetterSpacing(mAddedLetterSpacingInPx / wp.getTextSize());  // Convert to Em
        }

        int spanStart = runStart;
@@ -1277,7 +1320,8 @@ public class TextLine {
            @Nullable float[] advances, int advancesIndex, @Nullable LineInfo lineInfo,
            int runFlag) {
        if (mIsJustifying) {
            wp.setWordSpacing(mAddedWidthForJustify);
            wp.setWordSpacing(mAddedWordSpacingInPx);
            wp.setLetterSpacing(mAddedLetterSpacingInPx / wp.getTextSize());  // Convert to Em
        }
        // Get metrics first (even for empty strings or "0" width runs)
        if (drawBounds != null && fmi == null) {
+129 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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 android.text

import android.platform.test.flag.junit.DeviceFlagsValueProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class TextLineJustificationTest {

    @Rule
    @JvmField
    val mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule()

    private val PAINT = TextPaint().apply {
        textSize = 10f // make 1em = 10px
    }

    private fun makeTextLine(cs: CharSequence, paint: TextPaint) = TextLine.obtain().apply {
        set(paint, cs, 0, cs.length, Layout.DIR_LEFT_TO_RIGHT, Layout.DIRS_ALL_LEFT_TO_RIGHT, false,
                null, 0, 0, false)
    }

    private fun getClusterCount(cs: CharSequence, paint: TextPaint) = TextLine.LineInfo().apply {
        makeTextLine(cs, paint).also {
            it.metrics(null, null, false, this)
            TextLine.recycle(it)
        }
    }.clusterCount

    fun justifyTest_WithoutJustify() {
        val line = "Hello, World."
        val tl = makeTextLine(line, PAINT)

        // Without calling justify method, justifying should be false and all added spaces should
        // be zeros.
        assertThat(tl.isJustifying).isFalse()
        assertThat(tl.addedWordSpacingInPx).isEqualTo(0)
        assertThat(tl.addedLetterSpacingInPx).isEqualTo(0)
    }

    @Test
    fun justifyTest_IntrCharacter_Latin() {
        val line = "Hello, World."
        val clusterCount = getClusterCount(line, PAINT)
        val originalWidth = Layout.getDesiredWidth(line, PAINT)
        val extraWidth = 100f

        val tl = makeTextLine(line, PAINT)
        tl.justify(Layout.JUSTIFICATION_MODE_INTER_CHARACTER, originalWidth + extraWidth)

        assertThat(tl.isJustifying).isTrue()
        assertThat(tl.addedWordSpacingInPx).isEqualTo(0)
        assertThat(tl.addedLetterSpacingInPx).isEqualTo(extraWidth / (clusterCount - 1))

        TextLine.recycle(tl)
    }

    @Test
    fun justifyTest_IntrCharacter_Japanese() {
        val line = "\u672C\u65E5\u306F\u6674\u5929\u306A\u308A\u3002"
        val clusterCount = getClusterCount(line, PAINT)
        val originalWidth = Layout.getDesiredWidth(line, PAINT)
        val extraWidth = 100f

        val tl = makeTextLine(line, PAINT)
        tl.justify(Layout.JUSTIFICATION_MODE_INTER_CHARACTER, originalWidth + extraWidth)

        assertThat(tl.isJustifying).isTrue()
        assertThat(tl.addedWordSpacingInPx).isEqualTo(0)
        assertThat(tl.addedLetterSpacingInPx).isEqualTo(extraWidth / (clusterCount - 1))

        TextLine.recycle(tl)
    }

    @Test
    fun justifyTest_IntrWord_Latin() {
        val line = "Hello, World."
        val originalWidth = Layout.getDesiredWidth(line, PAINT)
        val extraWidth = 100f

        val tl = makeTextLine(line, PAINT)
        tl.justify(Layout.JUSTIFICATION_MODE_INTER_WORD, originalWidth + extraWidth)

        assertThat(tl.isJustifying).isTrue()
        // This text contains only one whitespace, so word spacing should be same to the extraWidth.
        assertThat(tl.addedWordSpacingInPx).isEqualTo(extraWidth)
        assertThat(tl.addedLetterSpacingInPx).isEqualTo(0)

        TextLine.recycle(tl)
    }

    @Test
    fun justifyTest_IntrWord_Japanese() {
        val line = "\u672C\u65E5\u306F\u6674\u0020\u5929\u306A\u308A\u3002"
        val originalWidth = Layout.getDesiredWidth(line, PAINT)
        val extraWidth = 100f

        val tl = makeTextLine(line, PAINT)
        tl.justify(Layout.JUSTIFICATION_MODE_INTER_WORD, originalWidth + extraWidth)

        assertThat(tl.isJustifying).isTrue()
        // This text contains only one whitespace, so word spacing should be same to the extraWidth.
        assertThat(tl.addedWordSpacingInPx).isEqualTo(extraWidth)
        assertThat(tl.addedLetterSpacingInPx).isEqualTo(0)

        TextLine.recycle(tl)
    }
}
 No newline at end of file
+1 −1
Original line number Diff line number Diff line
@@ -53,7 +53,7 @@ public class TextLineTest {
        final float originalWidth = tl.metrics(null, null, false, null);
        final float expandedWidth = 2 * originalWidth;

        tl.justify(expandedWidth);
        tl.justify(Layout.JUSTIFICATION_MODE_INTER_WORD, expandedWidth);
        final float newWidth = tl.metrics(null, null, false, null);
        TextLine.recycle(tl);
        return Math.abs(newWidth - expandedWidth) < 0.5;
Loading