blob: 7e41878b3d4eb808adaa29d28d02cded094e6212 [file] [log] [blame]
/*
* Copyright (C) 2010 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.FloatRange;
import android.annotation.IntRange;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.text.MeasuredText;
import android.text.AutoGrowArray.ByteArray;
import android.text.AutoGrowArray.FloatArray;
import android.text.AutoGrowArray.IntArray;
import android.text.Layout.Directions;
import android.text.style.MetricAffectingSpan;
import android.text.style.ReplacementSpan;
import android.util.Pools.SynchronizedPool;
import java.util.Arrays;
/**
* MeasuredParagraph provides text information for rendering purpose.
*
* The first motivation of this class is identify the text directions and retrieving individual
* character widths. However retrieving character widths is slower than identifying text directions.
* Thus, this class provides several builder methods for specific purposes.
*
* - buildForBidi:
* Compute only text directions.
* - buildForMeasurement:
* Compute text direction and all character widths.
* - buildForStaticLayout:
* This is bit special. StaticLayout also needs to know text direction and character widths for
* line breaking, but all things are done in native code. Similarly, text measurement is done
* in native code. So instead of storing result to Java array, this keeps the result in native
* code since there is no good reason to move the results to Java layer.
*
* In addition to the character widths, some additional information is computed for each purposes,
* e.g. whole text length for measurement or font metrics for static layout.
*
* MeasuredParagraph is NOT a thread safe object.
* @hide
*/
public class MeasuredParagraph {
private static final char OBJECT_REPLACEMENT_CHARACTER = '\uFFFC';
private MeasuredParagraph() {} // Use build static functions instead.
private static final SynchronizedPool<MeasuredParagraph> sPool = new SynchronizedPool<>(1);
private static @NonNull MeasuredParagraph obtain() { // Use build static functions instead.
final MeasuredParagraph mt = sPool.acquire();
return mt != null ? mt : new MeasuredParagraph();
}
/**
* Recycle the MeasuredParagraph.
*
* Do not call any methods after you call this method.
*/
public void recycle() {
release();
sPool.release(this);
}
// The casted original text.
//
// This may be null if the passed text is not a Spanned.
private @Nullable Spanned mSpanned;
// The start offset of the target range in the original text (mSpanned);
private @IntRange(from = 0) int mTextStart;
// The length of the target range in the original text.
private @IntRange(from = 0) int mTextLength;
// The copied character buffer for measuring text.
//
// The length of this array is mTextLength.
private @Nullable char[] mCopiedBuffer;
// The whole paragraph direction.
private @Layout.Direction int mParaDir;
// True if the text is LTR direction and doesn't contain any bidi characters.
private boolean mLtrWithoutBidi;
// The bidi level for individual characters.
//
// This is empty if mLtrWithoutBidi is true.
private @NonNull ByteArray mLevels = new ByteArray();
// The whole width of the text.
// See getWholeWidth comments.
private @FloatRange(from = 0.0f) float mWholeWidth;
// Individual characters' widths.
// See getWidths comments.
private @Nullable FloatArray mWidths = new FloatArray();
// The span end positions.
// See getSpanEndCache comments.
private @Nullable IntArray mSpanEndCache = new IntArray(4);
// The font metrics.
// See getFontMetrics comments.
private @Nullable IntArray mFontMetrics = new IntArray(4 * 4);
// The native MeasuredParagraph.
private @Nullable MeasuredText mMeasuredText;
// Following two objects are for avoiding object allocation.
private @NonNull TextPaint mCachedPaint = new TextPaint();
private @Nullable Paint.FontMetricsInt mCachedFm;
/**
* Releases internal buffers.
*/
public void release() {
reset();
mLevels.clearWithReleasingLargeArray();
mWidths.clearWithReleasingLargeArray();
mFontMetrics.clearWithReleasingLargeArray();
mSpanEndCache.clearWithReleasingLargeArray();
}
/**
* Resets the internal state for starting new text.
*/
private void reset() {
mSpanned = null;
mCopiedBuffer = null;
mWholeWidth = 0;
mLevels.clear();
mWidths.clear();
mFontMetrics.clear();
mSpanEndCache.clear();
mMeasuredText = null;
}
/**
* Returns the length of the paragraph.
*
* This is always available.
*/
public int getTextLength() {
return mTextLength;
}
/**
* Returns the characters to be measured.
*
* This is always available.
*/
public @NonNull char[] getChars() {
return mCopiedBuffer;
}
/**
* Returns the paragraph direction.
*
* This is always available.
*/
public @Layout.Direction int getParagraphDir() {
return mParaDir;
}
/**
* Returns the directions.
*
* This is always available.
*/
public Directions getDirections(@IntRange(from = 0) int start, // inclusive
@IntRange(from = 0) int end) { // exclusive
if (mLtrWithoutBidi) {
return Layout.DIRS_ALL_LEFT_TO_RIGHT;
}
final int length = end - start;
return AndroidBidi.directions(mParaDir, mLevels.getRawArray(), start, mCopiedBuffer, start,
length);
}
/**
* Returns the whole text width.
*
* This is available only if the MeasuredParagraph is computed with buildForMeasurement.
* Returns 0 in other cases.
*/
public @FloatRange(from = 0.0f) float getWholeWidth() {
return mWholeWidth;
}
/**
* Returns the individual character's width.
*
* This is available only if the MeasuredParagraph is computed with buildForMeasurement.
* Returns empty array in other cases.
*/
public @NonNull FloatArray getWidths() {
return mWidths;
}
/**
* Returns the MetricsAffectingSpan end indices.
*
* If the input text is not a spanned string, this has one value that is the length of the text.
*
* This is available only if the MeasuredParagraph is computed with buildForStaticLayout.
* Returns empty array in other cases.
*/
public @NonNull IntArray getSpanEndCache() {
return mSpanEndCache;
}
/**
* Returns the int array which holds FontMetrics.
*
* This array holds the repeat of top, bottom, ascent, descent of font metrics value.
*
* This is available only if the MeasuredParagraph is computed with buildForStaticLayout.
* Returns empty array in other cases.
*/
public @NonNull IntArray getFontMetrics() {
return mFontMetrics;
}
/**
* Returns the native ptr of the MeasuredParagraph.
*
* This is available only if the MeasuredParagraph is computed with buildForStaticLayout.
* Returns null in other cases.
*/
public MeasuredText getMeasuredText() {
return mMeasuredText;
}
/**
* Returns the width of the given range.
*
* This is not available if the MeasuredParagraph is computed with buildForBidi.
* Returns 0 if the MeasuredParagraph is computed with buildForBidi.
*
* @param start the inclusive start offset of the target region in the text
* @param end the exclusive end offset of the target region in the text
*/
public float getWidth(int start, int end) {
if (mMeasuredText == null) {
// We have result in Java.
final float[] widths = mWidths.getRawArray();
float r = 0.0f;
for (int i = start; i < end; ++i) {
r += widths[i];
}
return r;
} else {
// We have result in native.
return mMeasuredText.getWidth(start, end);
}
}
/**
* Retrieves the bounding rectangle that encloses all of the characters, with an implied origin
* at (0, 0).
*
* This is available only if the MeasuredParagraph is computed with buildForStaticLayout.
*/
public void getBounds(@IntRange(from = 0) int start, @IntRange(from = 0) int end,
@NonNull Rect bounds) {
mMeasuredText.getBounds(start, end, bounds);
}
/**
* Returns a width of the character at the offset.
*
* This is available only if the MeasuredParagraph is computed with buildForStaticLayout.
*/
public float getCharWidthAt(@IntRange(from = 0) int offset) {
return mMeasuredText.getCharWidthAt(offset);
}
/**
* Generates new MeasuredParagraph for Bidi computation.
*
* If recycle is null, this returns new instance. If recycle is not null, this fills computed
* result to recycle and returns recycle.
*
* @param text the character sequence to be measured
* @param start the inclusive start offset of the target region in the text
* @param end the exclusive end offset of the target region in the text
* @param textDir the text direction
* @param recycle pass existing MeasuredParagraph if you want to recycle it.
*
* @return measured text
*/
public static @NonNull MeasuredParagraph buildForBidi(@NonNull CharSequence text,
@IntRange(from = 0) int start,
@IntRange(from = 0) int end,
@NonNull TextDirectionHeuristic textDir,
@Nullable MeasuredParagraph recycle) {
final MeasuredParagraph mt = recycle == null ? obtain() : recycle;
mt.resetAndAnalyzeBidi(text, start, end, textDir);
return mt;
}
/**
* Generates new MeasuredParagraph for measuring texts.
*
* If recycle is null, this returns new instance. If recycle is not null, this fills computed
* result to recycle and returns recycle.
*
* @param paint the paint to be used for rendering the text.
* @param text the character sequence to be measured
* @param start the inclusive start offset of the target region in the text
* @param end the exclusive end offset of the target region in the text
* @param textDir the text direction
* @param recycle pass existing MeasuredParagraph if you want to recycle it.
*
* @return measured text
*/
public static @NonNull MeasuredParagraph buildForMeasurement(@NonNull TextPaint paint,
@NonNull CharSequence text,
@IntRange(from = 0) int start,
@IntRange(from = 0) int end,
@NonNull TextDirectionHeuristic textDir,
@Nullable MeasuredParagraph recycle) {
final MeasuredParagraph mt = recycle == null ? obtain() : recycle;
mt.resetAndAnalyzeBidi(text, start, end, textDir);
mt.mWidths.resize(mt.mTextLength);
if (mt.mTextLength == 0) {
return mt;
}
if (mt.mSpanned == null) {
// No style change by MetricsAffectingSpan. Just measure all text.
mt.applyMetricsAffectingSpan(
paint, null /* spans */, start, end, null /* native builder ptr */);
} else {
// There may be a MetricsAffectingSpan. Split into span transitions and apply styles.
int spanEnd;
for (int spanStart = start; spanStart < end; spanStart = spanEnd) {
spanEnd = mt.mSpanned.nextSpanTransition(spanStart, end, MetricAffectingSpan.class);
MetricAffectingSpan[] spans = mt.mSpanned.getSpans(spanStart, spanEnd,
MetricAffectingSpan.class);
spans = TextUtils.removeEmptySpans(spans, mt.mSpanned, MetricAffectingSpan.class);
mt.applyMetricsAffectingSpan(
paint, spans, spanStart, spanEnd, null /* native builder ptr */);
}
}
return mt;
}
/**
* Generates new MeasuredParagraph for StaticLayout.
*
* If recycle is null, this returns new instance. If recycle is not null, this fills computed
* result to recycle and returns recycle.
*
* @param paint the paint to be used for rendering the text.
* @param text the character sequence to be measured
* @param start the inclusive start offset of the target region in the text
* @param end the exclusive end offset of the target region in the text
* @param textDir the text direction
* @param computeHyphenation true if need to compute hyphenation, otherwise false
* @param computeLayout true if need to compute full layout, otherwise false.
* @param hint pass if you already have measured paragraph.
* @param recycle pass existing MeasuredParagraph if you want to recycle it.
*
* @return measured text
*/
public static @NonNull MeasuredParagraph buildForStaticLayout(
@NonNull TextPaint paint,
@NonNull CharSequence text,
@IntRange(from = 0) int start,
@IntRange(from = 0) int end,
@NonNull TextDirectionHeuristic textDir,
boolean computeHyphenation,
boolean computeLayout,
@Nullable MeasuredParagraph hint,
@Nullable MeasuredParagraph recycle) {
final MeasuredParagraph mt = recycle == null ? obtain() : recycle;
mt.resetAndAnalyzeBidi(text, start, end, textDir);
final MeasuredText.Builder builder;
if (hint == null) {
builder = new MeasuredText.Builder(mt.mCopiedBuffer)
.setComputeHyphenation(computeHyphenation)
.setComputeLayout(computeLayout);
} else {
builder = new MeasuredText.Builder(hint.mMeasuredText);
}
if (mt.mTextLength == 0) {
// Need to build empty native measured text for StaticLayout.
// TODO: Stop creating empty measured text for empty lines.
mt.mMeasuredText = builder.build();
} else {
if (mt.mSpanned == null) {
// No style change by MetricsAffectingSpan. Just measure all text.
mt.applyMetricsAffectingSpan(paint, null /* spans */, start, end, builder);
mt.mSpanEndCache.append(end);
} else {
// There may be a MetricsAffectingSpan. Split into span transitions and apply
// styles.
int spanEnd;
for (int spanStart = start; spanStart < end; spanStart = spanEnd) {
spanEnd = mt.mSpanned.nextSpanTransition(spanStart, end,
MetricAffectingSpan.class);
MetricAffectingSpan[] spans = mt.mSpanned.getSpans(spanStart, spanEnd,
MetricAffectingSpan.class);
spans = TextUtils.removeEmptySpans(spans, mt.mSpanned,
MetricAffectingSpan.class);
mt.applyMetricsAffectingSpan(paint, spans, spanStart, spanEnd, builder);
mt.mSpanEndCache.append(spanEnd);
}
}
mt.mMeasuredText = builder.build();
}
return mt;
}
/**
* Reset internal state and analyzes text for bidirectional runs.
*
* @param text the character sequence to be measured
* @param start the inclusive start offset of the target region in the text
* @param end the exclusive end offset of the target region in the text
* @param textDir the text direction
*/
private void resetAndAnalyzeBidi(@NonNull CharSequence text,
@IntRange(from = 0) int start, // inclusive
@IntRange(from = 0) int end, // exclusive
@NonNull TextDirectionHeuristic textDir) {
reset();
mSpanned = text instanceof Spanned ? (Spanned) text : null;
mTextStart = start;
mTextLength = end - start;
if (mCopiedBuffer == null || mCopiedBuffer.length != mTextLength) {
mCopiedBuffer = new char[mTextLength];
}
TextUtils.getChars(text, start, end, mCopiedBuffer, 0);
// Replace characters associated with ReplacementSpan to U+FFFC.
if (mSpanned != null) {
ReplacementSpan[] spans = mSpanned.getSpans(start, end, ReplacementSpan.class);
for (int i = 0; i < spans.length; i++) {
int startInPara = mSpanned.getSpanStart(spans[i]) - start;
int endInPara = mSpanned.getSpanEnd(spans[i]) - start;
// The span interval may be larger and must be restricted to [start, end)
if (startInPara < 0) startInPara = 0;
if (endInPara > mTextLength) endInPara = mTextLength;
Arrays.fill(mCopiedBuffer, startInPara, endInPara, OBJECT_REPLACEMENT_CHARACTER);
}
}
if ((textDir == TextDirectionHeuristics.LTR
|| textDir == TextDirectionHeuristics.FIRSTSTRONG_LTR
|| textDir == TextDirectionHeuristics.ANYRTL_LTR)
&& TextUtils.doesNotNeedBidi(mCopiedBuffer, 0, mTextLength)) {
mLevels.clear();
mParaDir = Layout.DIR_LEFT_TO_RIGHT;
mLtrWithoutBidi = true;
} else {
final int bidiRequest;
if (textDir == TextDirectionHeuristics.LTR) {
bidiRequest = Layout.DIR_REQUEST_LTR;
} else if (textDir == TextDirectionHeuristics.RTL) {
bidiRequest = Layout.DIR_REQUEST_RTL;
} else if (textDir == TextDirectionHeuristics.FIRSTSTRONG_LTR) {
bidiRequest = Layout.DIR_REQUEST_DEFAULT_LTR;
} else if (textDir == TextDirectionHeuristics.FIRSTSTRONG_RTL) {
bidiRequest = Layout.DIR_REQUEST_DEFAULT_RTL;
} else {
final boolean isRtl = textDir.isRtl(mCopiedBuffer, 0, mTextLength);
bidiRequest = isRtl ? Layout.DIR_REQUEST_RTL : Layout.DIR_REQUEST_LTR;
}
mLevels.resize(mTextLength);
mParaDir = AndroidBidi.bidi(bidiRequest, mCopiedBuffer, mLevels.getRawArray());
mLtrWithoutBidi = false;
}
}
private void applyReplacementRun(@NonNull ReplacementSpan replacement,
@IntRange(from = 0) int start, // inclusive, in copied buffer
@IntRange(from = 0) int end, // exclusive, in copied buffer
@Nullable MeasuredText.Builder builder) {
// Use original text. Shouldn't matter.
// TODO: passing uninitizlied FontMetrics to developers. Do we need to keep this for
// backward compatibility? or Should we initialize them for getFontMetricsInt?
final float width = replacement.getSize(
mCachedPaint, mSpanned, start + mTextStart, end + mTextStart, mCachedFm);
if (builder == null) {
// Assigns all width to the first character. This is the same behavior as minikin.
mWidths.set(start, width);
if (end > start + 1) {
Arrays.fill(mWidths.getRawArray(), start + 1, end, 0.0f);
}
mWholeWidth += width;
} else {
builder.appendReplacementRun(mCachedPaint, end - start, width);
}
}
private void applyStyleRun(@IntRange(from = 0) int start, // inclusive, in copied buffer
@IntRange(from = 0) int end, // exclusive, in copied buffer
@Nullable MeasuredText.Builder builder) {
if (mLtrWithoutBidi) {
// If the whole text is LTR direction, just apply whole region.
if (builder == null) {
mWholeWidth += mCachedPaint.getTextRunAdvances(
mCopiedBuffer, start, end - start, start, end - start, false /* isRtl */,
mWidths.getRawArray(), start);
} else {
builder.appendStyleRun(mCachedPaint, end - start, false /* isRtl */);
}
} else {
// If there is multiple bidi levels, split into individual bidi level and apply style.
byte level = mLevels.get(start);
// Note that the empty text or empty range won't reach this method.
// Safe to search from start + 1.
for (int levelStart = start, levelEnd = start + 1;; ++levelEnd) {
if (levelEnd == end || mLevels.get(levelEnd) != level) { // transition point
final boolean isRtl = (level & 0x1) != 0;
if (builder == null) {
final int levelLength = levelEnd - levelStart;
mWholeWidth += mCachedPaint.getTextRunAdvances(
mCopiedBuffer, levelStart, levelLength, levelStart, levelLength,
isRtl, mWidths.getRawArray(), levelStart);
} else {
builder.appendStyleRun(mCachedPaint, levelEnd - levelStart, isRtl);
}
if (levelEnd == end) {
break;
}
levelStart = levelEnd;
level = mLevels.get(levelEnd);
}
}
}
}
private void applyMetricsAffectingSpan(
@NonNull TextPaint paint,
@Nullable MetricAffectingSpan[] spans,
@IntRange(from = 0) int start, // inclusive, in original text buffer
@IntRange(from = 0) int end, // exclusive, in original text buffer
@Nullable MeasuredText.Builder builder) {
mCachedPaint.set(paint);
// XXX paint should not have a baseline shift, but...
mCachedPaint.baselineShift = 0;
final boolean needFontMetrics = builder != null;
if (needFontMetrics && mCachedFm == null) {
mCachedFm = new Paint.FontMetricsInt();
}
ReplacementSpan replacement = null;
if (spans != null) {
for (int i = 0; i < spans.length; i++) {
MetricAffectingSpan span = spans[i];
if (span instanceof ReplacementSpan) {
// The last ReplacementSpan is effective for backward compatibility reasons.
replacement = (ReplacementSpan) span;
} else {
// TODO: No need to call updateMeasureState for ReplacementSpan as well?
span.updateMeasureState(mCachedPaint);
}
}
}
final int startInCopiedBuffer = start - mTextStart;
final int endInCopiedBuffer = end - mTextStart;
if (builder != null) {
mCachedPaint.getFontMetricsInt(mCachedFm);
}
if (replacement != null) {
applyReplacementRun(replacement, startInCopiedBuffer, endInCopiedBuffer, builder);
} else {
applyStyleRun(startInCopiedBuffer, endInCopiedBuffer, builder);
}
if (needFontMetrics) {
if (mCachedPaint.baselineShift < 0) {
mCachedFm.ascent += mCachedPaint.baselineShift;
mCachedFm.top += mCachedPaint.baselineShift;
} else {
mCachedFm.descent += mCachedPaint.baselineShift;
mCachedFm.bottom += mCachedPaint.baselineShift;
}
mFontMetrics.append(mCachedFm.top);
mFontMetrics.append(mCachedFm.bottom);
mFontMetrics.append(mCachedFm.ascent);
mFontMetrics.append(mCachedFm.descent);
}
}
/**
* Returns the maximum index that the accumulated width not exceeds the width.
*
* If forward=false is passed, returns the minimum index from the end instead.
*
* This only works if the MeasuredParagraph is computed with buildForMeasurement.
* Undefined behavior in other case.
*/
@IntRange(from = 0) int breakText(int limit, boolean forwards, float width) {
float[] w = mWidths.getRawArray();
if (forwards) {
int i = 0;
while (i < limit) {
width -= w[i];
if (width < 0.0f) break;
i++;
}
while (i > 0 && mCopiedBuffer[i - 1] == ' ') i--;
return i;
} else {
int i = limit - 1;
while (i >= 0) {
width -= w[i];
if (width < 0.0f) break;
i--;
}
while (i < limit - 1 && (mCopiedBuffer[i + 1] == ' ' || w[i + 1] == 0.0f)) {
i++;
}
return limit - i - 1;
}
}
/**
* Returns the length of the substring.
*
* This only works if the MeasuredParagraph is computed with buildForMeasurement.
* Undefined behavior in other case.
*/
@FloatRange(from = 0.0f) float measure(int start, int limit) {
float width = 0;
float[] w = mWidths.getRawArray();
for (int i = start; i < limit; ++i) {
width += w[i];
}
return width;
}
/**
* This only works if the MeasuredParagraph is computed with buildForStaticLayout.
*/
public @IntRange(from = 0) int getMemoryUsage() {
return mMeasuredText.getMemoryUsage();
}
}