/* * 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 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 java.util.regex.Matcher; import java.util.regex.Pattern; import org.xml.sax.XMLReader; import static android.graphics.Paint.Style.FILL; import static android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE; import static android.text.Spanned.SPAN_MARK_MARK; public class HtmlUtils { private static final String TAG_HTML = "html"; 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 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 final TagHandler TAG_HANDLER = new TagHandler() { private LinkedList<ListSeparator> listElements = new LinkedList<>(); 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) || TAG_OL.equalsIgnoreCase(tag)) { if (opening) { listElements.add(new ListSeparator(TAG_OL.equalsIgnoreCase(tag))); } else if (!listElements.isEmpty()) { listElements.removeLast(); } if (!opening && listElements.isEmpty()) output.append('\n'); return; } if (TAG_LI.equalsIgnoreCase(tag) && opening && !listElements.isEmpty()) { listElements.getLast().append(output, listElements.size()); return; } if (TAG_CODE.equalsIgnoreCase(tag)) { if (opening) { startSpan(new TypefaceSpan("monospace"), output); } else { endSpan(TypefaceSpan.class, output); } } if (TAG_PRE.equalsIgnoreCase(tag)) { output.append('\n'); if (opening) { startSpan(new TypefaceSpan("monospace"), output); } else { endSpan(TypefaceSpan.class, output); } } if (TAG_HTML.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); } } } }; 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); } /** * Rewrite relative URLs in HTML fetched e.g. from markdown files. */ public static String rewriteRelativeUrls(final String html, final String repoUser, final String repoName, final String branch) { final String baseUrl = "https://raw.github.com/" + repoUser + "/" + repoName + "/" + branch; final StringBuffer sb = new StringBuffer(); final Pattern p = Pattern.compile("(href|src)=\"(\\S+)\""); final Matcher m = p.matcher(html); while (m.find()) { String url = m.group(2); if (!url.contains("://") && !url.startsWith("#")) { if (url.startsWith("/")) { url = baseUrl + url; } else { url = baseUrl + "/" + url; } } m.appendReplacement(sb, Matcher.quoteReplacement(m.group(1) + "=\"" + url + "\"")); } m.appendTail(sb); return sb.toString(); } /** * Encode HTML * * @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 * * @return formatted HTML */ public static 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 replace(formatted, PARAGRAPH_START, BREAK); replace(formatted, PARAGRAPH_END, BREAK); formatPres(formatted); formatEmailFragments(formatted); trim(formatted); return formatted; } private static void 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); } } private static void replace(final StringBuilder input, final String from, final String to) { int start = input.indexOf(from); int length = from.length(); while (start != -1) { input.delete(start, start + length); input.insert(start, to); start = input.indexOf(from, start); } } private static void replace(final StringBuilder input, final String fromStart, final String fromEnd, final String toStart, final String toEnd) { int start = input.indexOf(fromStart); while (start != -1) { input.delete(start, start + fromStart.length()); input.insert(start, toStart); int end = input.indexOf(fromEnd, start + toStart.length()); if (end != -1) { input.delete(end, end + fromEnd.length()); input.insert(end, toEnd); } start = input.indexOf(fromStart); } } private static void 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()); } } /** * Remove email fragment 'div' tag and replace newlines with 'br' tags * * @return input */ private static void 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); } } /** * Remove leading and trailing whitespace */ private static void 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(); } } private static class ReplySpan implements LeadingMarginSpan { private final int color; public ReplySpan() { color = 0xffDDDDDD; } @Override public int getLeadingMargin(boolean first) { return 18; } 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 class ListSeparator { private int count; public ListSeparator(boolean ordered) { if (ordered) { count = 1; } else { count = -1; } } public void 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(' '); } } }