package yuku.alkitab.base.widget;
import android.graphics.Typeface;
import android.os.Handler;
import android.os.Looper;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.SpannableStringBuilder;
import android.text.TextPaint;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.LeadingMarginSpan;
import android.text.style.MetricAffectingSpan;
import android.text.style.StyleSpan;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import yuku.alkitab.base.App;
import yuku.alkitab.base.S;
import yuku.alkitab.base.util.Highlights;
public class VerseRenderer {
public static final String TAG = VerseRenderer.class.getSimpleName();
static final char[] superscriptDigits = {'\u2070', '\u00b9', '\u00b2', '\u00b3', '\u2074', '\u2075', '\u2076', '\u2077', '\u2078', '\u2079'};
public static final char XREF_MARK = '\u203b';
public static class VerseNumberSpan extends MetricAffectingSpan {
private final boolean applyColor;
public VerseNumberSpan(boolean applyColor) {
this.applyColor = applyColor;
}
@Override public void updateMeasureState(TextPaint tp) {
tp.baselineShift += (int) (tp.ascent() * 0.3f + 0.5f);
tp.setTextSize(tp.getTextSize() * 0.7f);
}
@Override public void updateDrawState(TextPaint tp) {
tp.baselineShift += (int) (tp.ascent() * 0.3f + 0.5f);
tp.setTextSize(tp.getTextSize() * 0.7f);
if (applyColor) {
tp.setColor(S.applied.verseNumberColor);
}
}
}
public static class FormattedTextResult {
public CharSequence result;
}
static LeadingMarginSpan.Standard createLeadingMarginSpan(int all) {
return createLeadingMarginSpan(all, all);
}
static LeadingMarginSpan.Standard createLeadingMarginSpan(int first, int rest) {
return new LeadingMarginSpan.Standard(first, rest);
}
private static ThreadLocal<char[]> buf_char_ = new ThreadLocal<char[]>() {
@Override protected char[] initialValue() {
return new char[1024];
}
};
private static ThreadLocal<StringBuilder> buf_tag_ = new ThreadLocal<StringBuilder>() {
@Override protected StringBuilder initialValue() {
return new StringBuilder(100);
}
};
/**
* @param lText TextView for verse text, but can be null if rendering is for non-display
* @param lVerseNumber TextView for verse number, but can be null if rendering is for non-display
* @param ftr optional container for result that contains the verse text with span formattings, without the verse numbers
* @return how many characters was used before the real start of verse text. This will be > 0 if the verse number is embedded inside lText.
*/
public static int render(@Nullable final TextView lText, @Nullable final TextView lVerseNumber, final int ari, @NonNull final String text, final String verseNumberText, @Nullable final Highlights.Info highlightInfo, final boolean checked, @Nullable final VerseInlineLinkSpan.Factory inlineLinkSpanFactory, @Nullable final FormattedTextResult ftr) {
// @@ = start a verse containing paragraphs or formatting
// @0 = start with indent 0 [paragraph]
// @1 = start with indent 1 [paragraph]
// @2 = start with indent 2 [paragraph]
// @3 = start with indent 3 [paragraph]
// @4 = start with indent 4 [paragraph]
// @6 = start of red text [formatting]
// @5 = end of red text [formatting]
// @9 = start of italic [formatting]
// @7 = end of italic [formatting]
// @8 = put a blank line to the next verse [formatting]
// @^ = start-of-paragraph marker
// @< to @> = special tags (not visible for unsupported tags) [can be considered formatting]
// @/ = end of special tags (closing tag) (As of 2013-10-04, all special tags must be closed) [can be considered formatting]
final int text_len = text.length();
// Determine if this verse text is a simple verse or formatted verse.
// Formatted verses start with "@@".
// Second character must be '@' too, if not it's wrong, we will fallback to simple render.
if (text_len < 2 || text.charAt(0) != '@' || text.charAt(1) != '@') {
if (ftr != null) {
ftr.result = text;
}
return simpleRender(lText, lVerseNumber, text, verseNumberText, highlightInfo, checked);
}
// optimization, to prevent repeated calls to charAt()
char[] text_c = buf_char_.get();
if (text_c.length < text_len) {
text_c = new char[text_len];
buf_char_.set(text_c);
}
text.getChars(0, text_len, text_c, 0);
/**
* '0'..'4', '^' indent 0..4 or new para
* -1 undefined
*/
int paraType = -1;
/**
* position of start of paragraph
*/
int startPara = 0;
/**
* position of start red marker
*/
int startRed = -1;
/**
* position of start italic marker
*/
int startItalic = -1;
/**
* whether we are inside a tag (between @< and @>)
*/
boolean inSpecialTag = false;
/**
* Reusable tag buffer
*/
final StringBuilder tag = buf_tag_.get();
final SpannableStringBuilder sb = new SpannableStringBuilder();
// this has two uses
// - to check whether a verse number has been written
// - to check whether we need to put a new line when encountering a new para
final int startPosAfterVerseNumber;
int pos = 2; // we start after "@@"
// write verse number inline only when no @[1234^] on the beginning of text
if (text_len >= 4 && text_c[pos] == '@' && (text_c[pos+1] == '^' || (text_c[pos+1] >= '1' && text_c[pos+1] <= '4'))) {
// don't write verse number now
startPosAfterVerseNumber = 0;
} else {
sb.append(verseNumberText);
sb.setSpan(new VerseRenderer.VerseNumberSpan(!checked), 0, sb.length(), 0);
sb.append(" ");
startPosAfterVerseNumber = sb.length();
}
// initialize lVerseNumber to have no padding first
if (lVerseNumber != null) {
lVerseNumber.setPadding(0, 0, 0, 0);
}
while (true) {
if (pos >= text_len) {
break;
}
int nextAt = text.indexOf('@', pos);
if (nextAt == -1) { // no more, just append till the end of everything and exit
sb.append(text, pos, text_len);
break;
}
if (inSpecialTag) { // are we in a tag?
// we have encountered the end of a tag
tag.setLength(0);
tag.append(text, pos, nextAt);
pos = nextAt;
} else {
// insert all text until the nextAt
if (nextAt != pos) /* extra check for optimization (prevent call to sb.append()) */ {
sb.append(text, pos, nextAt);
pos = nextAt;
}
}
pos++;
// just in case
if (pos >= text_len) {
break;
}
char marker = text_c[pos];
switch (marker) {
case '0':
case '1':
case '2':
case '3':
case '4':
case '^':
// apply previous
applyParaStyle(sb, paraType, startPara, verseNumberText, startPosAfterVerseNumber > 0);
if (sb.length() > startPosAfterVerseNumber) {
sb.append("\n");
}
// store current
paraType = marker;
startPara = sb.length();
break;
case '6':
startRed = sb.length();
break;
case '5':
if (startRed != -1) {
if (!checked) {
sb.setSpan(new ForegroundColorSpan(S.applied.fontRedColor), startRed, sb.length(), 0);
}
startRed = -1;
}
break;
case '9':
startItalic = sb.length();
break;
case '7':
if (startItalic != -1) {
sb.setSpan(new StyleSpan(Typeface.ITALIC), startItalic, sb.length(), 0);
startItalic = -1;
}
break;
case '8':
sb.append("\n");
break;
case '<':
inSpecialTag = true;
break;
case '>':
inSpecialTag = false;
break;
case '/':
processSpecialTag(sb, tag, inlineLinkSpanFactory, ari);
break;
}
pos++;
}
// apply unapplied
applyParaStyle(sb, paraType, startPara, verseNumberText, startPosAfterVerseNumber > 0);
if (highlightInfo != null) {
final BackgroundColorSpan span = new BackgroundColorSpan(Highlights.alphaMix(highlightInfo.colorRgb));
if (highlightInfo.shouldRenderAsPartialForVerseText(sb.subSequence(startPosAfterVerseNumber, sb.length()))) {
final int start = startPosAfterVerseNumber + highlightInfo.partial.startOffset;
final int end = startPosAfterVerseNumber + highlightInfo.partial.endOffset;
if (end > start) {
sb.setSpan(span, start, end, 0);
} else {
sb.setSpan(span, end, start, 0);
}
} else {
sb.setSpan(span, startPosAfterVerseNumber, sb.length(), 0);
}
}
if (lText != null) {
lText.setText(sb);
}
// show verse on lVerseNumber if not shown in lText yet
if (lVerseNumber != null) {
if (startPosAfterVerseNumber > 0) {
lVerseNumber.setVisibility(View.GONE);
lVerseNumber.setText("");
} else {
lVerseNumber.setVisibility(View.VISIBLE);
lVerseNumber.setText(verseNumberText);
}
}
if (ftr != null) {
if (startPosAfterVerseNumber == 0) {
ftr.result = sb;
} else {
ftr.result = sb.subSequence(startPosAfterVerseNumber, sb.length());
}
}
return startPosAfterVerseNumber;
}
static void processSpecialTag(final SpannableStringBuilder sb, final StringBuilder tag, @Nullable final VerseInlineLinkSpan.Factory inlineLinkSpanFactory, final int ari) {
final int sb_len = sb.length();
if (tag.length() >= 2) {
// Footnote
if (tag.charAt(0) == 'f') {
try {
final int field = Integer.parseInt(tag.substring(1));
if (field < 1 || field > 255) {
throw new NumberFormatException();
}
appendSuperscriptNumber(sb, field);
if (inlineLinkSpanFactory != null) {
sb.setSpan(inlineLinkSpanFactory.create(VerseInlineLinkSpan.Type.footnote, ari << 8 | field), sb_len, sb.length(), 0);
}
} catch (NumberFormatException e) {
reportInvalidSpecialTag("Invalid footnote tag at ari 0x" + Integer.toHexString(ari) + ": " + tag);
}
} else if (tag.charAt(0) == 'x') {
try {
final int field = Integer.parseInt(tag.substring(1));
if (field < 1 || field > 255) {
throw new NumberFormatException();
}
sb.append(XREF_MARK); // star mark
if (inlineLinkSpanFactory != null) {
sb.setSpan(inlineLinkSpanFactory.create(VerseInlineLinkSpan.Type.xref, ari << 8 | field), sb_len, sb.length(), 0);
}
} catch (NumberFormatException e) {
reportInvalidSpecialTag("Invalid xref tag at ari 0x" + Integer.toHexString(ari) + ": " + tag);
}
}
}
}
static Toast invalidSpecialTagToast;
static void reportInvalidSpecialTag(final String msg) {
new Handler(Looper.getMainLooper()).post(() -> {
if (invalidSpecialTagToast == null) {
invalidSpecialTagToast = Toast.makeText(App.context, msg, Toast.LENGTH_SHORT);
} else {
invalidSpecialTagToast.setText(msg);
}
invalidSpecialTagToast.show();
});
}
public static void appendSuperscriptNumber(final SpannableStringBuilder sb, final int field) {
if (field >= 0 && field < 10) {
sb.append(superscriptDigits[field]);
} else if (field >= 10) {
final String s = String.valueOf(field);
for (int i = 0; i < s.length(); i++) {
final char c = s.charAt(i);
sb.append(superscriptDigits[c - '0']);
}
}
// should not be negative
}
/**
* @param paraType if -1, will apply the same thing as when paraType is 0 and firstLineWithVerseNumber is true.
* @param firstLineWithVerseNumber If this is formatting for the first paragraph of a verse and that paragraph contains a verse number, so we can apply more lefty first-line indent.
* This only applies if the paraType is 0.
*/
static void applyParaStyle(SpannableStringBuilder sb, int paraType, int startPara, String verseNumberText, boolean firstLineWithVerseNumber) {
int len = sb.length();
if (startPara == len) return;
int indentSpacingExtraUnits = verseNumberText.length() < 3? 0: (verseNumberText.length() - 2);
switch (paraType) {
case -1:
sb.setSpan(createLeadingMarginSpan(0, S.applied.indentParagraphRest), startPara, len, 0);
break;
case '0':
if (firstLineWithVerseNumber) {
sb.setSpan(createLeadingMarginSpan(0, S.applied.indentParagraphRest), startPara, len, 0);
} else {
sb.setSpan(createLeadingMarginSpan(S.applied.indentParagraphRest), startPara, len, 0);
}
break;
case '1':
sb.setSpan(createLeadingMarginSpan(S.applied.indentSpacing1 + indentSpacingExtraUnits * S.applied.indentSpacingExtra), startPara, len, 0);
break;
case '2':
sb.setSpan(createLeadingMarginSpan(S.applied.indentSpacing2 + indentSpacingExtraUnits * S.applied.indentSpacingExtra), startPara, len, 0);
break;
case '3':
sb.setSpan(createLeadingMarginSpan(S.applied.indentSpacing3 + indentSpacingExtraUnits * S.applied.indentSpacingExtra), startPara, len, 0);
break;
case '4':
sb.setSpan(createLeadingMarginSpan(S.applied.indentSpacing4 + indentSpacingExtraUnits * S.applied.indentSpacingExtra), startPara, len, 0);
break;
case '^':
sb.setSpan(createLeadingMarginSpan(S.applied.indentParagraphFirst, S.applied.indentParagraphRest), startPara, len, 0);
break;
}
}
/**
* @return how many characters was used before the real start of verse text. This will be > 0 if the verse number is embedded inside lText.
*/
public static int simpleRender(@Nullable TextView lText, @Nullable TextView lVerseNumber, String text, String verseNumberText, @Nullable final Highlights.Info highlightInfo, boolean checked) {
final SpannableStringBuilder sb = new SpannableStringBuilder();
// verse number
sb.append(verseNumberText).append(" ");
sb.setSpan(new VerseRenderer.VerseNumberSpan(!checked), 0, verseNumberText.length(), 0);
final int startPosAfterVerseNumber = sb.length();
// verse text
sb.append(text);
sb.setSpan(createLeadingMarginSpan(0, S.applied.indentParagraphRest), 0, sb.length(), 0);
if (highlightInfo != null) {
final BackgroundColorSpan span = new BackgroundColorSpan(Highlights.alphaMix(highlightInfo.colorRgb));
if (highlightInfo.shouldRenderAsPartialForVerseText(text)) {
final int start = startPosAfterVerseNumber + highlightInfo.partial.startOffset;
final int end = startPosAfterVerseNumber + highlightInfo.partial.endOffset;
if (end > start) {
sb.setSpan(span, start, end, 0);
} else {
sb.setSpan(span, end, start, 0);
}
} else {
sb.setSpan(span, startPosAfterVerseNumber, sb.length(), 0);
}
}
if (lText != null) {
lText.setText(sb);
}
// initialize lVerseNumber to have no padding first
if (lVerseNumber != null) {
lVerseNumber.setPadding(0, 0, 0, 0);
lVerseNumber.setVisibility(View.GONE);
lVerseNumber.setText("");
}
return startPosAfterVerseNumber;
}
}