/* * Copyright 2012 GitHub Inc. * * 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 com.github.mobile.util; import static android.graphics.Paint.Style.FILL; import static android.os.Build.VERSION.SDK_INT; import static android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH; import static android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE; import static android.text.Spanned.SPAN_MARK_MARK; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Paint.Style; import android.text.Editable; import android.text.Html.ImageGetter; import android.text.Html.TagHandler; import android.text.Layout; import android.text.Spanned; import android.text.TextUtils; import android.text.style.LeadingMarginSpan; import android.text.style.QuoteSpan; import android.text.style.StrikethroughSpan; import android.text.style.TypefaceSpan; import java.util.LinkedList; import org.xml.sax.XMLReader; /** * HTML Utilities */ public class HtmlUtils { private static class ReplySpan implements LeadingMarginSpan { private final int color = 0xffDDDDDD; @Override public int getLeadingMargin(boolean first) { return 18; } @Override public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom, CharSequence text, int start, int end, boolean first, Layout layout) { final Style style = p.getStyle(); final int color = p.getColor(); p.setStyle(FILL); p.setColor(this.color); c.drawRect(x, top, x + dir * 6, bottom, p); p.setStyle(style); p.setColor(color); } } private static final String TAG_ROOT = "githubroot"; private static final String ROOT_START = '<' + TAG_ROOT + '>'; private static final String ROOT_END = "</" + TAG_ROOT + '>'; private static final String TOGGLE_START = "<span class=\"email-hidden-toggle\">"; private static final String TOGGLE_END = "</span>"; private static final String REPLY_START = "<div class=\"email-quoted-reply\">"; private static final String REPLY_END = "</div>"; private static final String SIGNATURE_START = "<div class=\"email-signature-reply\">"; private static final String SIGNATURE_END = "</div>"; private static final String EMAIL_START = "<div class=\"email-fragment\">"; private static final String EMAIL_END = "</div>"; private static final String HIDDEN_REPLY_START = "<div class=\"email-hidden-reply\" style=\" display:none\">"; private static final String HIDDEN_REPLY_END = "</div>"; private static final String BREAK = "<br>"; private static final String PARAGRAPH_START = "<p>"; private static final String PARAGRAPH_END = "</p>"; private static final String BLOCKQUOTE_START = "<blockquote>"; private static final String BLOCKQUOTE_END = "</blockquote>"; private static final String SPACE = " "; private static final String PRE_START = "<pre>"; private static final String PRE_END = "</pre>"; private static final String CODE_START = "<code>"; private static final String CODE_END = "</code>"; private static class ListSeparator { private int count; public ListSeparator(boolean ordered) { count = ordered ? 1 : -1; } public ListSeparator append(Editable output, int indentLevel) { output.append('\n'); for (int i = 0; i < indentLevel * 2; i++) output.append(' '); if (count != -1) { output.append(Integer.toString(count)).append('.'); count++; } else output.append('\u2022'); output.append(' ').append(' '); return this; } } private static final TagHandler TAG_HANDLER = new TagHandler() { private static final String TAG_DEL = "del"; private static final String TAG_UL = "ul"; private static final String TAG_OL = "ol"; private static final String TAG_LI = "li"; private static final String TAG_CODE = "code"; private static final String TAG_PRE = "pre"; private int indentLevel; private final LinkedList<ListSeparator> listElements = new LinkedList<ListSeparator>(); @Override public void handleTag(final boolean opening, final String tag, final Editable output, final XMLReader xmlReader) { if (TAG_DEL.equalsIgnoreCase(tag)) { if (opening) startSpan(new StrikethroughSpan(), output); else endSpan(StrikethroughSpan.class, output); return; } if (TAG_UL.equalsIgnoreCase(tag)) { if (opening) { listElements.addFirst(new ListSeparator(false)); indentLevel++; } else { listElements.removeFirst(); indentLevel--; } if (!opening && indentLevel == 0) output.append('\n'); return; } if (TAG_OL.equalsIgnoreCase(tag)) { if (opening) { listElements.addFirst(new ListSeparator(true)); indentLevel++; } else { listElements.removeFirst(); indentLevel--; } if (!opening && indentLevel == 0) output.append('\n'); return; } if (TAG_LI.equalsIgnoreCase(tag) && opening) { listElements.getFirst().append(output, indentLevel); return; } if (TAG_CODE.equalsIgnoreCase(tag)) { if (opening) startSpan(new TypefaceSpan("monospace"), output); else endSpan(TypefaceSpan.class, output); return; } if (TAG_PRE.equalsIgnoreCase(tag)) { output.append('\n'); if (opening) startSpan(new TypefaceSpan("monospace"), output); else endSpan(TypefaceSpan.class, output); return; } if (TAG_ROOT.equalsIgnoreCase(tag) && !opening) { // Remove leading newlines while (output.length() > 0 && output.charAt(0) == '\n') output.delete(0, 1); // Remove trailing newlines int last = output.length() - 1; while (last >= 0 && output.charAt(last) == '\n') { output.delete(last, last + 1); last = output.length() - 1; } QuoteSpan[] quoteSpans = output.getSpans(0, output.length(), QuoteSpan.class); for (QuoteSpan span : quoteSpans) { int start = output.getSpanStart(span); int end = output.getSpanEnd(span); output.removeSpan(span); output.setSpan(new ReplySpan(), start, end, SPAN_EXCLUSIVE_EXCLUSIVE); } return; } } }; private static Object getLast(final Spanned text, final Class<?> kind) { Object[] spans = text.getSpans(0, text.length(), kind); return spans.length > 0 ? spans[spans.length - 1] : null; } private static void startSpan(Object span, Editable output) { int length = output.length(); output.setSpan(span, length, length, SPAN_MARK_MARK); } private static void endSpan(Class<?> type, Editable output) { int length = output.length(); Object span = getLast(output, type); int start = output.getSpanStart(span); output.removeSpan(span); if (start != length) output.setSpan(span, start, length, SPAN_EXCLUSIVE_EXCLUSIVE); } /** * Encode HTML * * @param html * @return html */ public static CharSequence encode(final String html) { return encode(html, null); } /** * Encode HTML * * @param html * @param imageGetter * @return html */ public static CharSequence encode(final String html, final ImageGetter imageGetter) { if (TextUtils.isEmpty(html)) return ""; return android.text.Html.fromHtml(html, imageGetter, TAG_HANDLER); } /** * Format given HTML string so it is ready to be presented in a text view * * @param html * @return formatted HTML */ public static final CharSequence format(final String html) { if (html == null) return ""; if (html.length() == 0) return ""; StringBuilder formatted = new StringBuilder(html); // Remove e-mail toggle link strip(formatted, TOGGLE_START, TOGGLE_END); // Remove signature strip(formatted, SIGNATURE_START, SIGNATURE_END); // Replace div with e-mail content with block quote replace(formatted, REPLY_START, REPLY_END, BLOCKQUOTE_START, BLOCKQUOTE_END); // Remove hidden div strip(formatted, HIDDEN_REPLY_START, HIDDEN_REPLY_END); // Replace paragraphs with breaks if (replace(formatted, PARAGRAPH_START, BREAK)) replace(formatted, PARAGRAPH_END, BREAK); formatPres(formatted); formatEmailFragments(formatted); formatIncorrectStyles(formatted); trim(formatted); formatted.insert(0, ROOT_START); formatted.append(ROOT_END); return formatted; } private static void formatIncorrectStyles(final StringBuilder input) { // em and strong tag styles are swapped on pre-4.0 so swap them back // using alternate tags that don't exhibit the incorrect styling. // http://code.google.com/p/android/issues/detail?id=3473 if (SDK_INT < ICE_CREAM_SANDWICH) { replaceTag(input, "em", "i"); replaceTag(input, "strong", "b"); } } private static StringBuilder strip(final StringBuilder input, final String prefix, final String suffix) { int start = input.indexOf(prefix); while (start != -1) { int end = input.indexOf(suffix, start + prefix.length()); if (end == -1) end = input.length(); input.delete(start, end + suffix.length()); start = input.indexOf(prefix, start); } return input; } private static boolean replace(final StringBuilder input, final String from, final String to) { int start = input.indexOf(from); if (start == -1) return false; final int fromLength = from.length(); final int toLength = to.length(); while (start != -1) { input.replace(start, start + fromLength, to); start = input.indexOf(from, start + toLength); } return true; } private static void replaceTag(final StringBuilder input, final String from, final String to) { if (replace(input, '<' + from + '>', '<' + to + '>')) replace(input, "</" + from + '>', "</" + to + '>'); } private static StringBuilder replace(final StringBuilder input, final String fromStart, final String fromEnd, final String toStart, final String toEnd) { int start = input.indexOf(fromStart); if (start == -1) return input; final int fromStartLength = fromStart.length(); final int fromEndLength = fromEnd.length(); final int toStartLength = toStart.length(); while (start != -1) { input.replace(start, start + fromStartLength, toStart); int end = input.indexOf(fromEnd, start + toStartLength); if (end != -1) input.replace(end, end + fromEndLength, toEnd); start = input.indexOf(fromStart); } return input; } private static StringBuilder formatPres(final StringBuilder input) { int start = input.indexOf(PRE_START); final int spaceAdvance = SPACE.length() - 1; final int breakAdvance = BREAK.length() - 1; while (start != -1) { int end = input.indexOf(PRE_END, start + PRE_START.length()); if (end == -1) break; // Skip over code element if (input.indexOf(CODE_START, start) == start) start += CODE_START.length(); if (input.indexOf(CODE_END, start) == end - CODE_END.length()) end -= CODE_END.length(); for (int i = start; i < end; i++) { switch (input.charAt(i)) { case ' ': input.deleteCharAt(i); input.insert(i, SPACE); start += spaceAdvance; end += spaceAdvance; break; case '\t': input.deleteCharAt(i); input.insert(i, SPACE); start += spaceAdvance; end += spaceAdvance; for (int j = 0; j < 3; j++) { input.insert(i, SPACE); start += spaceAdvance + 1; end += spaceAdvance + 1; } break; case '\n': input.deleteCharAt(i); // Ignore if last character is a newline if (i + 1 < end) { input.insert(i, BREAK); start += breakAdvance; end += breakAdvance; } break; } } start = input.indexOf(PRE_START, end + PRE_END.length()); } return input; } /** * Remove email fragment 'div' tag and replace newlines with 'br' tags * * @param input * @return input */ private static StringBuilder formatEmailFragments(final StringBuilder input) { int emailStart = input.indexOf(EMAIL_START); int breakAdvance = BREAK.length() - 1; while (emailStart != -1) { int startLength = EMAIL_START.length(); int emailEnd = input.indexOf(EMAIL_END, emailStart + startLength); if (emailEnd == -1) break; input.delete(emailEnd, emailEnd + EMAIL_END.length()); input.delete(emailStart, emailStart + startLength); int fullEmail = emailEnd - startLength; for (int i = emailStart; i < fullEmail; i++) if (input.charAt(i) == '\n') { input.deleteCharAt(i); input.insert(i, BREAK); i += breakAdvance; fullEmail += breakAdvance; } emailStart = input.indexOf(EMAIL_START, fullEmail); } return input; } /** * Remove leading and trailing whitespace * * @param input */ private static StringBuilder trim(final StringBuilder input) { int length = input.length(); int breakLength = BREAK.length(); while (length > 0) { if (input.indexOf(BREAK) == 0) input.delete(0, breakLength); else if (length >= breakLength && input.lastIndexOf(BREAK) == length - breakLength) input.delete(length - breakLength, length); else if (Character.isWhitespace(input.charAt(0))) input.deleteCharAt(0); else if (Character.isWhitespace(input.charAt(length - 1))) input.deleteCharAt(length - 1); else break; length = input.length(); } return input; } }