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

Commit 48a980e1 authored by Tyler Freeman's avatar Tyler Freeman
Browse files

fix(high contrast text): draw contrasting backgrounds when text changes color via Spans

We take into account the CharacterStyle spans that change the color of
the text to be different than the original global Paint color. This
matches the high contrast background to each span of text that is a
different color.

Also, fix the isHighContrastTextDark() function which was converting
LAB wrong because of incorrect documentation (see b/343778621)

Fix: 340552436
Flag: ACONFIG com.android.graphics.hwui.flags.high_contrast_text_small_text_rect TRUNKFOOD
Test: atest frameworks/base/core/tests/coretests/src/android/text/LayoutTest.java
 && atest frameworks/base/core/tests/coretests/src/android/text/SpanColorsTest.java

Change-Id: I8e9723409b8219ad9c869dc5a7c767d2ec669939
parent 99bbe52e
Loading
Loading
Loading
Loading
+72 −9
Original line number Diff line number Diff line
@@ -18,9 +18,10 @@ package android.text;

import static com.android.graphics.hwui.flags.Flags.highContrastTextLuminance;
import static com.android.text.flags.Flags.FLAG_FIX_LINE_HEIGHT_FOR_LOCALE;
import static com.android.text.flags.Flags.FLAG_USE_BOUNDS_FOR_WIDTH;
import static com.android.text.flags.Flags.FLAG_LETTER_SPACING_JUSTIFICATION;
import static com.android.text.flags.Flags.FLAG_USE_BOUNDS_FOR_WIDTH;

import android.annotation.ColorInt;
import android.annotation.FlaggedApi;
import android.annotation.FloatRange;
import android.annotation.IntDef;
@@ -398,6 +399,20 @@ public abstract class Layout {
        mUseBoundsForWidth = useBoundsForWidth;
        mShiftDrawingOffsetForStartOverhang = shiftDrawingOffsetForStartOverhang;
        mMinimumFontMetrics = minimumFontMetrics;

        initSpanColors();
    }

    private void initSpanColors() {
        if (mSpannedText && Flags.highContrastTextSmallTextRect()) {
            if (mSpanColors == null) {
                mSpanColors = new SpanColors();
            } else {
                mSpanColors.recycle();
            }
        } else {
            mSpanColors = null;
        }
    }

    /**
@@ -417,6 +432,7 @@ public abstract class Layout {
        mSpacingMult = spacingmult;
        mSpacingAdd = spacingadd;
        mSpannedText = text instanceof Spanned;
        initSpanColors();
    }

    /**
@@ -643,20 +659,20 @@ public abstract class Layout {
            return null;
        }

        return isHighContrastTextDark() ? BlendMode.MULTIPLY : BlendMode.DIFFERENCE;
        return isHighContrastTextDark(mPaint.getColor()) ? BlendMode.MULTIPLY
                : BlendMode.DIFFERENCE;
    }

    private boolean isHighContrastTextDark() {
    private boolean isHighContrastTextDark(@ColorInt int color) {
        // High-contrast text mode
        // Determine if the text is black-on-white or white-on-black, so we know what blendmode will
        // give the highest contrast and most realistic text color.
        // This equation should match the one in libs/hwui/hwui/DrawTextFunctor.h
        if (highContrastTextLuminance()) {
            var lab = new double[3];
            ColorUtils.colorToLAB(mPaint.getColor(), lab);
            return lab[0] < 0.5;
            ColorUtils.colorToLAB(color, lab);
            return lab[0] < 50.0;
        } else {
            var color = mPaint.getColor();
            int channelSum = Color.red(color) + Color.green(color) + Color.blue(color);
            return channelSum < (128 * 3);
        }
@@ -1010,15 +1026,22 @@ public abstract class Layout {
        var padding = Math.max(HIGH_CONTRAST_TEXT_BORDER_WIDTH_MIN_PX,
                mPaint.getTextSize() * HIGH_CONTRAST_TEXT_BORDER_WIDTH_FACTOR);

        var originalTextColor = mPaint.getColor();
        var bgPaint = mWorkPlainPaint;
        bgPaint.reset();
        bgPaint.setColor(isHighContrastTextDark() ? Color.WHITE : Color.BLACK);
        bgPaint.setColor(isHighContrastTextDark(originalTextColor) ? Color.WHITE : Color.BLACK);
        bgPaint.setStyle(Paint.Style.FILL);

        int start = getLineStart(firstLine);
        int end = getLineEnd(lastLine);
        // Draw a separate background rectangle for each line of text, that only surrounds the
        // characters on that line.
        // characters on that line. But we also have to check the text color for each character, and
        // make sure we are drawing the correct contrasting background. This is because Spans can
        // change colors throughout the text and we'll need to match our backgrounds.
        if (mSpannedText && mSpanColors != null) {
            mSpanColors.init(mWorkPaint, ((Spanned) mText), start, end);
        }

        forEachCharacterBounds(
                start,
                end,
@@ -1028,13 +1051,24 @@ public abstract class Layout {
                    int mLastLineNum = -1;
                    final RectF mLineBackground = new RectF();

                    @ColorInt int mLastColor = originalTextColor;

                    @Override
                    public void onCharacterBounds(int index, int lineNum, float left, float top,
                            float right, float bottom) {
                        if (lineNum != mLastLineNum) {

                        var newBackground = determineContrastingBackgroundColor(index);
                        var hasBgColorChanged = newBackground != bgPaint.getColor();

                        if (lineNum != mLastLineNum || hasBgColorChanged) {
                            // Draw what we have so far, then reset the rect and update its color
                            drawRect();
                            mLineBackground.set(left, top, right, bottom);
                            mLastLineNum = lineNum;

                            if (hasBgColorChanged) {
                                bgPaint.setColor(newBackground);
                            }
                        } else {
                            mLineBackground.union(left, top, right, bottom);
                        }
@@ -1051,8 +1085,36 @@ public abstract class Layout {
                            canvas.drawRect(mLineBackground, bgPaint);
                        }
                    }

                    private int determineContrastingBackgroundColor(int index) {
                        if (!mSpannedText || mSpanColors == null) {
                            // The text is not Spanned. it's all one color.
                            return bgPaint.getColor();
                        }

                        // Sometimes the color will change, but not enough to warrant a background
                        // color change. e.g. from black to dark grey still gets clamped to black,
                        // so the background stays white and we don't need to draw a fresh
                        // background.
                        var textColor = mSpanColors.getColorAt(index);
                        if (textColor == SpanColors.NO_COLOR_FOUND) {
                            textColor = originalTextColor;
                        }
                        var hasColorChanged = textColor != mLastColor;
                        if (hasColorChanged) {
                            mLastColor = textColor;

                            return isHighContrastTextDark(textColor) ? Color.WHITE : Color.BLACK;
                        }

                        return bgPaint.getColor();
                    }
                }
        );

        if (mSpanColors != null) {
            mSpanColors.recycle();
        }
    }

    /**
@@ -3580,6 +3642,7 @@ public abstract class Layout {
    private float mSpacingAdd;
    private static final Rect sTempRect = new Rect();
    private boolean mSpannedText;
    @Nullable private SpanColors mSpanColors;
    private TextDirectionHeuristic mTextDir;
    private SpanSet<LineBackgroundSpan> mLineBackgroundSpans;
    private boolean mIncludePad;
+89 −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.annotation.ColorInt;
import android.annotation.Nullable;
import android.graphics.Color;
import android.text.style.CharacterStyle;

/**
 * Finds the foreground text color for the given Spanned text so you can iterate through each color
 * change.
 *
 * @hide
 */
public class SpanColors {
    public static final @ColorInt int NO_COLOR_FOUND = Color.TRANSPARENT;

    private final SpanSet<CharacterStyle> mCharacterStyleSpanSet =
            new SpanSet<>(CharacterStyle.class);
    @Nullable private TextPaint mWorkPaint;

    public SpanColors() {}

    /**
     * Init for the given text
     *
     * @param workPaint A temporary TextPaint object that will be used to calculate the colors. The
     *                  paint properties will be mutated on calls to {@link #getColorAt(int)} so
     *                  make sure to reset it before you use it for something else.
     * @param spanned the text to examine
     * @param start index to start at
     * @param end index of the end
     */
    public void init(TextPaint workPaint, Spanned spanned, int start, int end) {
        mWorkPaint = workPaint;
        mCharacterStyleSpanSet.init(spanned, start, end);
    }

    /**
     * Removes all internal references to the spans to avoid memory leaks.
     */
    public void recycle() {
        mWorkPaint = null;
        mCharacterStyleSpanSet.recycle();
    }

    /**
     * Calculates the foreground color of the text at the given character index.
     *
     * <p>You must call {@link #init(TextPaint, Spanned, int, int)} before calling this
     */
    public @ColorInt int getColorAt(int index) {
        var finalColor = NO_COLOR_FOUND;
        // Reset the paint so if we get a CharacterStyle that doesn't actually specify color,
        // (like UnderlineSpan), we still return no color found.
        mWorkPaint.setColor(finalColor);
        for (int k = 0; k < mCharacterStyleSpanSet.numberOfSpans; k++) {
            if ((index >= mCharacterStyleSpanSet.spanStarts[k])
                    && (index <= mCharacterStyleSpanSet.spanEnds[k])) {
                final CharacterStyle span = mCharacterStyleSpanSet.spans[k];
                span.updateDrawState(mWorkPaint);

                finalColor = calculateFinalColor(mWorkPaint);
            }
        }
        return finalColor;
    }

    private @ColorInt int calculateFinalColor(TextPaint workPaint) {
        // TODO: can we figure out what the getColorFilter() will do?
        //  if so, we also need to reset colorFilter before the loop in getColorAt()
        return workPaint.getColor();
    }
}
+78 −0
Original line number Diff line number Diff line
@@ -39,6 +39,7 @@ import android.platform.test.annotations.RequiresFlagsEnabled;
import android.platform.test.flag.junit.CheckFlagsRule;
import android.platform.test.flag.junit.DeviceFlagsValueProvider;
import android.text.Layout.Alignment;
import android.text.style.ForegroundColorSpan;
import android.text.style.StrikethroughSpan;

import androidx.test.filters.SmallTest;
@@ -933,6 +934,83 @@ public class LayoutTest {
        expect.that(numBackgroundsFound).isEqualTo(backgroundRectsDrawn);
    }

    @Test
    @RequiresFlagsEnabled(FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
    public void highContrastTextEnabled_testDrawMulticolorText_drawsBlackAndWhiteBackgrounds() {
        /*
        Here's what the final render should look like:

       Text  |   Background
     ========================
        al   |    BW
        w    |    WW
        ei   |    WW
        \t;  |    WW
        s    |    BB
        df   |    BB
        s    |    BB
        df   |    BB
        @    |    BB
      ------------------------
         */

        mTextPaint.setColor(Color.WHITE);

        mSpannedText.setSpan(
                // Can't use DKGREY because it is right on the cusp of clamping white
                new ForegroundColorSpan(0xFF332211),
                /* start= */ 1,
                /* end= */ 6,
                Spanned.SPAN_INCLUSIVE_EXCLUSIVE
        );
        mSpannedText.setSpan(
                new ForegroundColorSpan(Color.LTGRAY),
                /* start= */ 8,
                /* end= */ 11,
                Spanned.SPAN_INCLUSIVE_EXCLUSIVE
        );
        Layout layout = new StaticLayout(mSpannedText, mTextPaint, mWidth,
                mAlign, mSpacingMult, mSpacingAdd, /* includePad= */ false);

        final int width = 256;
        final int height = 256;
        MockCanvas c = new MockCanvas(width, height);
        c.setHighContrastTextEnabled(true);
        layout.draw(
                c,
                /* highlightPaths= */ null,
                /* highlightPaints= */ null,
                /* selectionPath= */ null,
                /* selectionPaint= */ null,
                /* cursorOffsetVertical= */ 0
        );
        List<MockCanvas.DrawCommand> drawCommands = c.getDrawCommands();
        var highlightsDrawn = 0;
        var numColorChangesWithinOneLine = 1;
        var textsDrawn = STATIC_LINE_COUNT + numColorChangesWithinOneLine;
        var backgroundRectsDrawn = STATIC_LINE_COUNT + numColorChangesWithinOneLine;
        expect.withMessage("wrong number of drawCommands: " + drawCommands)
                .that(drawCommands.size())
                .isEqualTo(textsDrawn + backgroundRectsDrawn + highlightsDrawn);

        var backgroundCommands = drawCommands.stream()
                .filter(it -> it.rect != null)
                .toList();

        expect.that(backgroundCommands.get(0).paint.getColor()).isEqualTo(Color.BLACK);
        expect.that(backgroundCommands.get(1).paint.getColor()).isEqualTo(Color.WHITE);
        expect.that(backgroundCommands.get(2).paint.getColor()).isEqualTo(Color.WHITE);
        expect.that(backgroundCommands.get(3).paint.getColor()).isEqualTo(Color.WHITE);
        expect.that(backgroundCommands.get(4).paint.getColor()).isEqualTo(Color.WHITE);
        expect.that(backgroundCommands.get(5).paint.getColor()).isEqualTo(Color.BLACK);
        expect.that(backgroundCommands.get(6).paint.getColor()).isEqualTo(Color.BLACK);
        expect.that(backgroundCommands.get(7).paint.getColor()).isEqualTo(Color.BLACK);
        expect.that(backgroundCommands.get(8).paint.getColor()).isEqualTo(Color.BLACK);
        expect.that(backgroundCommands.get(9).paint.getColor()).isEqualTo(Color.BLACK);

        expect.that(backgroundCommands.size()).isEqualTo(backgroundRectsDrawn);
    }

    private static final class MockCanvas extends Canvas {

        static class DrawCommand {
+78 −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 static com.google.common.truth.Truth.assertThat;

import android.graphics.Color;
import android.graphics.drawable.ShapeDrawable;
import android.platform.test.annotations.Presubmit;
import android.text.style.ForegroundColorSpan;
import android.text.style.ImageSpan;
import android.text.style.UnderlineSpan;

import androidx.test.filters.SmallTest;
import androidx.test.runner.AndroidJUnit4;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

@Presubmit
@SmallTest
@RunWith(AndroidJUnit4.class)
public class SpanColorsTest {
    private final TextPaint mWorkPaint = new TextPaint();
    private SpanColors mSpanColors;
    private SpannableString mSpannedText;

    @Before
    public void setup() {
        mSpanColors = new SpanColors();
        mSpannedText = new SpannableString("Hello world! This is a test.");
        mSpannedText.setSpan(new ForegroundColorSpan(Color.RED), 0, 4,
                Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
        mSpannedText.setSpan(new ForegroundColorSpan(Color.GREEN), 6, 11,
                Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
        mSpannedText.setSpan(new UnderlineSpan(), 5, 10, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
        mSpannedText.setSpan(new ImageSpan(new ShapeDrawable()), 1, 2,
                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        mSpannedText.setSpan(new ForegroundColorSpan(Color.BLUE), 12, 16,
                Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
    }

    @Test
    public void testNoColorFound() {
        mSpanColors.init(mWorkPaint, mSpannedText, 25, 30); // Beyond the spans
        assertThat(mSpanColors.getColorAt(27)).isEqualTo(SpanColors.NO_COLOR_FOUND);
    }

    @Test
    public void testSingleColorSpan() {
        mSpanColors.init(mWorkPaint, mSpannedText, 1, 4);
        assertThat(mSpanColors.getColorAt(3)).isEqualTo(Color.RED);
    }

    @Test
    public void testMultipleColorSpans() {
        mSpanColors.init(mWorkPaint, mSpannedText, 0, mSpannedText.length());
        assertThat(mSpanColors.getColorAt(2)).isEqualTo(Color.RED);
        assertThat(mSpanColors.getColorAt(5)).isEqualTo(SpanColors.NO_COLOR_FOUND);
        assertThat(mSpanColors.getColorAt(8)).isEqualTo(Color.GREEN);
        assertThat(mSpanColors.getColorAt(13)).isEqualTo(Color.BLUE);
    }
}