package what.whatandroid.comments; import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.style.BulletSpan; import android.text.style.URLSpan; import android.util.Patterns; import java.util.Map; import java.util.Stack; import java.util.TreeMap; import java.util.regex.Matcher; import java.util.regex.Pattern; import what.whatandroid.comments.tags.AlignTagStyle; import what.whatandroid.comments.tags.ArtistTagStyle; import what.whatandroid.comments.tags.BoldTagStyle; import what.whatandroid.comments.tags.CodeTagStyle; import what.whatandroid.comments.tags.ColorTagStyle; import what.whatandroid.comments.tags.HiddenTagStyle; import what.whatandroid.comments.tags.ImageTagStyle; import what.whatandroid.comments.tags.ImportantTagStyle; import what.whatandroid.comments.tags.ItalicTagStyle; import what.whatandroid.comments.tags.ModeratorTagStyle; import what.whatandroid.comments.tags.QuoteTagStyle; import what.whatandroid.comments.tags.SizeTagStyle; import what.whatandroid.comments.tags.StrikethroughTagStyle; import what.whatandroid.comments.tags.Tag; import what.whatandroid.comments.tags.TagStyle; import what.whatandroid.comments.tags.TorrentTagStyle; import what.whatandroid.comments.tags.URLTagStyle; import what.whatandroid.comments.tags.UnderlineTagStyle; import what.whatandroid.comments.tags.UserTagStyle; /** * Takes a bb formatted string and builds a formatted Spanned to display the text * Note: this doesn't properly handle nested tags of the same type, but I don't think * that's really used on the site so it's probably fine. */ public class WhatBBParser { private static final Map<String, TagStyle> tagStyles; /** * Patterns to validate opening and closing tags */ private static final Pattern TAG_OPEN = Pattern.compile("\\[([^\\]/]+[^\\]]*)\\]"), TAG_CLOSE = Pattern.compile("\\[/([^\\]/]+)\\]"); /** * Tags for bulleted lists and a pattern to match numbered lists. The pattern is used so that * we can figure out when to reset the counter. The site uses \r\n for line endings */ private static final String BULLET_LIST = "[*]"; private static final String NUM_LIST = "[#]"; private SpannableStringBuilder builder; static{ tagStyles = new TreeMap<String, TagStyle>(String.CASE_INSENSITIVE_ORDER); tagStyles.put("b", new BoldTagStyle()); tagStyles.put("i", new ItalicTagStyle()); tagStyles.put("u", new UnderlineTagStyle()); tagStyles.put("s", new StrikethroughTagStyle()); tagStyles.put("important", new ImportantTagStyle()); TagStyle t = new CodeTagStyle(); tagStyles.put("code", t); tagStyles.put("pre", t); tagStyles.put("align", new AlignTagStyle()); tagStyles.put("color", new ColorTagStyle()); tagStyles.put("size", new SizeTagStyle()); tagStyles.put("url", new URLTagStyle()); tagStyles.put("img", new ImageTagStyle()); tagStyles.put("quote", new QuoteTagStyle()); t = new HiddenTagStyle(); tagStyles.put("hide", t); tagStyles.put("spoiler", t); tagStyles.put("mature", t); tagStyles.put("artist", new ArtistTagStyle()); tagStyles.put("user", new UserTagStyle()); tagStyles.put("torrent", new TorrentTagStyle()); tagStyles.put("plain", null); tagStyles.putAll(ModeratorTagStyle.moderatorTags); } /** * Parse and apply styling to the bb text * * @return A Spannable containing the styled text */ public CharSequence parsebb(String bbText){ builder = new SpannableStringBuilder(bbText); SmileyProcessor.bbSmileytoEmoji(builder); parseBulletLists(); parseNumberedList(); Stack<Tag> tags = new Stack<Tag>(); //Plain tags cause other tag styles to not be applied, use this to track if we're in a //plain block or not boolean plain = false; //Run through the text and examine potential tags, parsing tags as we encounter them for (int start = indexOf(builder, "["), end = indexOf(builder, "]"); start != -1 && end != -1; start = indexOf(builder, "[", start + 1), end = indexOf(builder, "]", start)) { CharSequence block = builder.subSequence(start, end + 1); Matcher open = TAG_OPEN.matcher(block); //It's an opener if (open.find()){ if (!plain){ Tag t = new Tag(start, open.group(1)); if (tagStyles.containsKey(t.tag)){ start = openTag(tags, t); } plain = t.tag.equalsIgnoreCase("plain"); } } else { plain = false; Matcher close = TAG_CLOSE.matcher(block); //If it's a closer if (close.find() && tagStyles.containsKey(close.group(1))){ builder.delete(start, end + 1); //Pop-off and close all tags closed by this closer start = closeTags(tags, close.group(1), start); } } } //Clean up any tags that are being closed by the end of the text closeAllTags(tags); //Do a final pass to parse any plain urls in the text parsePlainUrls(); return builder; } /** * Open a tag and handle any potential special cases * * @param tags tag stack to push the tag onto * @param tag the tag being opened * @return the position to resume parsing from, required in the case of hidden tags */ private int openTag(Stack<Tag> tags, Tag tag){ //Hidden tags have their content hidden so we extract it into the tag and don't waste time parsing //that content, since it'll only be shown if the hidden tag is clicked if (tag.tag.equalsIgnoreCase("hide") || tag.tag.equalsIgnoreCase("mature")){ return parseHiddenTag(tag); } //If it's a self-closing image tag (the only type of tag that can self-close) handle the special case if (tag.tag.equalsIgnoreCase("img") && tag.param != null){ tag.end = tag.start + tag.tagLength; builder.replace(tag.start, tag.end, tagStyles.get(tag.tag).getStyle(tag.param, null)); } else { tags.push(tag); } return tag.start; } /** * Close all tags up to and including tag in the stack of tags * * @param tag tag name to close * @param start the start of the closing tag * @return location to resume parsing */ private int closeTags(Stack<Tag> tags, String tag, int start){ if (tags.empty()){ return start; } do { Tag t = tags.pop(); t.end = start; //If a user enters an improperly formatted tag this can occur and we need to skip the tag if (t.start + t.tagLength > t.end){ continue; } //plain tags give a null style, ie. just remove the tags but apply no formatting TagStyle style = tagStyles.get(t.tag); Spannable styled; if (style != null){ styled = style.getStyle(t.param, builder.subSequence(t.start + t.tagLength, t.end)); } else { styled = new SpannableString(builder.subSequence(t.start + t.tagLength, t.end)); } builder.replace(t.start, t.end, styled); //Account for differences in the length of the previous text and its styled replacement start += getOffset(t, styled); //We seem to need this extra offset when closing multiple tags. //TODO find a proper fix for this instead of the patch below, unless that's the best we can do? if (!t.tag.equalsIgnoreCase(tag)){ ++start; } else { break; } } while (!tags.empty()); return start; } /** * Close all tags in the stack and end them at the end of the builder. Used to close any remaining open * tags at the end of parsing, since these tags should run to the end of the text */ private void closeAllTags(Stack<Tag> tags){ while (!tags.empty()){ Tag t = tags.pop(); t.end = builder.length(); Spannable styled = tagStyles.get(t.tag).getStyle(t.param, builder.subSequence(t.start + t.tagLength, t.end)); builder.replace(t.start, t.end, styled); } } /** * Parse and conceal a hidden tag starting at some location * * @return the point after the end of the hidden text to resume parsing at */ private int parseHiddenTag(Tag t){ String closer = "[/" + t.tag + "]"; int s = indexOf(builder, closer, t.start); if (s == -1){ s = builder.length(); } else { builder.delete(s, s + closer.length()); } t.end = s; Spannable styled = tagStyles.get(t.tag).getStyle(t.param, builder.subSequence(t.start + t.tagLength, t.end)); builder.replace(t.start, t.end, styled); //Account for differences in the length of the previous text and its styled replacement s += getOffset(t, styled); return s; } /** * Get the offset to add to the position after the tag when it's replaced by * its styled text * * @param t tag being replaced * @param styled styled text replacing it * @return offset to subtract from a position after the tag sequence replaced by the styled * text to move it to the new ending position */ private static int getOffset(Tag t, CharSequence styled){ return -(t.end - t.start - styled.length() + 1); } /** * Find the index of the first occurrence of str after start in the spannable string builder * -1 is returned if not found. SpannableStringBuilder lacks indexOf so we need to do it * ourselves :( */ public static int indexOf(SpannableStringBuilder ssb, String str, int start){ if (start > ssb.length()){ return -1; } if (start < 0){ start = 0; } for (int i = start; i < ssb.length(); ++i){ if (ssb.charAt(i) == str.charAt(0)){ int j = 1; for (int k = i + 1; j < str.length() && k < ssb.length(); ++j, ++k){ if (ssb.charAt(k) != str.charAt(j)){ i = k; break; } } if (j == str.length()){ return i; } } } return -1; } public static int indexOf(SpannableStringBuilder ssb, String str){ return indexOf(ssb, str, 0); } /** * Parse any plain urls in the text and apply URL styling to them */ private void parsePlainUrls(){ Matcher urls = Patterns.WEB_URL.matcher(builder); while (urls.find()){ if (urls.group().startsWith("http")){ builder.setSpan(new URLSpan(urls.group()), urls.start(), urls.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } } /** * Parse any bulleted lists in the text and apply a bulleted list formatting to the list items */ private void parseBulletLists(){ for (int start = indexOf(builder, BULLET_LIST), end = indexOf(builder, "\n", start); start != -1; start = indexOf(builder, BULLET_LIST, end), end = indexOf(builder, "\n", start)){ //If the last thing in the text is a list item then go to the end if (end == -1){ end = builder.length() - 1; } builder.setSpan(new BulletSpan(BulletSpan.STANDARD_GAP_WIDTH), start + BULLET_LIST.length(), end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); builder.delete(start, start + BULLET_LIST.length()); //Account for the shorter length of the text with the tag removed end -= BULLET_LIST.length(); } } /** * Parse any numbered lists in the text and apply a numbered list formatting to the list items */ private void parseNumberedList(){ int id = 1, prev = -1; for (int start = indexOf(builder, NUM_LIST), end = indexOf(builder, "\n", start); start != -1; start = indexOf(builder, NUM_LIST, end), end = indexOf(builder, "\n", start)){ //If the last thing in the text is a list item then go to the end if (end == -1){ end = builder.length() - 1; } if (prev == start){ ++id; } else { id = 1; } String num = Integer.toString(id) + ". "; builder.replace(start, start + NUM_LIST.length(), num); //Account for the shorter length of the text with the tag removed prev = end - NUM_LIST.length() + num.length() + 1; end = prev; } } }