1 /**
2  * Copyright (c) 2014, Google Inc.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *     http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.mail.utils;
18 
19 import android.graphics.Color;
20 import android.graphics.Typeface;
21 import android.text.SpannableStringBuilder;
22 import android.text.Spanned;
23 import android.text.style.AbsoluteSizeSpan;
24 import android.text.style.ForegroundColorSpan;
25 import android.text.style.QuoteSpan;
26 import android.text.style.StyleSpan;
27 import android.text.style.TypefaceSpan;
28 import android.text.style.URLSpan;
29 import android.text.style.UnderlineSpan;
30 
31 import com.android.mail.analytics.AnalyticsTimer;
32 import com.google.android.mail.common.base.CharMatcher;
33 import com.google.android.mail.common.html.parser.HTML;
34 import com.google.android.mail.common.html.parser.HTML4;
35 import com.google.android.mail.common.html.parser.HtmlDocument;
36 import com.google.android.mail.common.html.parser.HtmlTree;
37 import com.google.common.collect.Lists;
38 
39 import java.util.LinkedList;
40 
41 public class HtmlUtils {
42 
43     static final String LOG_TAG = LogTag.getLogTag();
44 
45     /**
46      * Use our custom SpannedConverter to process the HtmlNode results from HtmlTree.
47      * @param html
48      * @return processed HTML as a Spanned
49      */
htmlToSpan(String html, HtmlTree.ConverterFactory factory)50     public static Spanned htmlToSpan(String html, HtmlTree.ConverterFactory factory) {
51         AnalyticsTimer.getInstance().trackStart(AnalyticsTimer.COMPOSE_HTML_TO_SPAN);
52         // Get the html "tree"
53         final HtmlTree htmlTree = com.android.mail.utils.Utils.getHtmlTree(html);
54         htmlTree.setConverterFactory(factory);
55         final Spanned spanned = htmlTree.getSpanned();
56         AnalyticsTimer.getInstance().logDuration(AnalyticsTimer.COMPOSE_HTML_TO_SPAN, true,
57                 "compose", "html_to_span", null);
58         LogUtils.i(LOG_TAG, "htmlToSpan completed, input: %d, result: %d", html.length(),
59                 spanned.length());
60         return spanned;
61     }
62 
63     /**
64      * Class that handles converting the html into a Spanned.
65      * This class will only handle a subset of the html tags. Below is the full list:
66      *   - bold
67      *   - italic
68      *   - underline
69      *   - font size
70      *   - font color
71      *   - font face
72      *   - a
73      *   - blockquote
74      *   - p
75      *   - div
76      */
77     public static class SpannedConverter implements HtmlTree.Converter<Spanned> {
78         // Pinto normal text size is 2 while normal for AbsoluteSizeSpan is 12.
79         // So 6 seems to be the magic number here. Html.toHtml also uses 6 as divider.
80         private static final int WEB_TO_ANDROID_SIZE_MULTIPLIER = 6;
81 
82         protected final SpannableStringBuilder mBuilder = new SpannableStringBuilder();
83         private final LinkedList<TagWrapper> mSeenTags = Lists.newLinkedList();
84 
85         private final HtmlTree.DefaultPlainTextConverter mTextConverter =
86                 new HtmlTree.DefaultPlainTextConverter();
87         private int mTextConverterIndex = 0;
88 
89         @Override
addNode(HtmlDocument.Node n, int nodeNum, int endNum)90         public void addNode(HtmlDocument.Node n, int nodeNum, int endNum) {
91             // Feed it into the plain text converter
92             mTextConverter.addNode(n, nodeNum, endNum);
93             if (n instanceof HtmlDocument.Tag) {
94                 handleStart((HtmlDocument.Tag) n);
95             } else if (n instanceof HtmlDocument.EndTag) {
96                 handleEnd((HtmlDocument.EndTag) n);
97             }
98             appendPlainTextFromConverter();
99         }
100 
appendPlainTextFromConverter()101         private void appendPlainTextFromConverter() {
102             String textString = mTextConverter.getObject();
103             if (textString.length() > mTextConverterIndex) {
104                 mBuilder.append(textString.substring(mTextConverterIndex));
105                 mTextConverterIndex = textString.length();
106             }
107         }
108 
109         /**
110          * Helper function to handle start tag
111          */
handleStart(HtmlDocument.Tag tag)112         protected void handleStart(HtmlDocument.Tag tag) {
113             if (!tag.isSelfTerminating()) {
114                 // Add to the stack of tags needing closing tag
115                 mSeenTags.push(new TagWrapper(tag, mBuilder.length()));
116             }
117         }
118 
119         /**
120          * Helper function to handle end tag
121          */
handleEnd(HtmlDocument.EndTag tag)122         protected void handleEnd(HtmlDocument.EndTag tag) {
123             TagWrapper lastSeen;
124             HTML.Element element = tag.getElement();
125             while ((lastSeen = mSeenTags.poll()) != null && lastSeen.tag.getElement() != null &&
126                     !lastSeen.tag.getElement().equals(element)) { }
127 
128             // Misformatted html, just ignore this tag
129             if (lastSeen == null) {
130                 return;
131             }
132 
133             Object marker = null;
134             if (HTML4.B_ELEMENT.equals(element)) {
135                 // BOLD
136                 marker = new StyleSpan(Typeface.BOLD);
137             } else if (HTML4.I_ELEMENT.equals(element)) {
138                 // ITALIC
139                 marker = new StyleSpan(Typeface.ITALIC);
140             } else if (HTML4.U_ELEMENT.equals(element)) {
141                 // UNDERLINE
142                 marker = new UnderlineSpan();
143             } else if (HTML4.A_ELEMENT.equals(element)) {
144                 // A HREF
145                 HtmlDocument.TagAttribute attr = lastSeen.tag.getAttribute(HTML4.HREF_ATTRIBUTE);
146                 // Ignore this tag if it doesn't have a link
147                 if (attr == null) {
148                     return;
149                 }
150                 marker = new URLSpan(attr.getValue());
151             } else if (HTML4.BLOCKQUOTE_ELEMENT.equals(element)) {
152                 // BLOCKQUOTE
153                 marker = new QuoteSpan();
154             } else if (HTML4.FONT_ELEMENT.equals(element)) {
155                 // FONT SIZE/COLOR/FACE, since this can insert more than one span
156                 // we special case it and return
157                 handleFont(lastSeen);
158             }
159 
160             final int start = lastSeen.startIndex;
161             final int end = mBuilder.length();
162             if (marker != null && start != end) {
163                 mBuilder.setSpan(marker, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
164             }
165         }
166 
167         /**
168          * Helper function to handle end font tags
169          */
handleFont(TagWrapper wrapper)170         private void handleFont(TagWrapper wrapper) {
171             final int start = wrapper.startIndex;
172             final int end = mBuilder.length();
173 
174             // check font color
175             HtmlDocument.TagAttribute attr = wrapper.tag.getAttribute(HTML4.COLOR_ATTRIBUTE);
176             if (attr != null) {
177                 int c = Color.parseColor(attr.getValue());
178                 if (c != -1) {
179                     mBuilder.setSpan(new ForegroundColorSpan(c | 0xFF000000), start, end,
180                             Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
181                 }
182             }
183 
184             // check font size
185             attr = wrapper.tag.getAttribute(HTML4.SIZE_ATTRIBUTE);
186             if (attr != null) {
187                 int i = Integer.parseInt(attr.getValue());
188                 if (i != -1) {
189                     mBuilder.setSpan(new AbsoluteSizeSpan(i * WEB_TO_ANDROID_SIZE_MULTIPLIER,
190                             true /* use dip */), start, end,Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
191                 }
192             }
193 
194             // check font typeface
195             attr = wrapper.tag.getAttribute(HTML4.FACE_ATTRIBUTE);
196             if (attr != null) {
197                 String[] families = attr.getValue().split(",");
198                 for (String family : families) {
199                     mBuilder.setSpan(new TypefaceSpan(family.trim()), start, end,
200                             Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
201                 }
202             }
203         }
204 
205         @Override
getPlainTextLength()206         public int getPlainTextLength() {
207             return mBuilder.length();
208         }
209 
210         @Override
getObject()211         public Spanned getObject() {
212             return mBuilder;
213         }
214 
215         private static class TagWrapper {
216             final HtmlDocument.Tag tag;
217             final int startIndex;
218 
TagWrapper(HtmlDocument.Tag tag, int startIndex)219             TagWrapper(HtmlDocument.Tag tag, int startIndex) {
220                 this.tag = tag;
221                 this.startIndex = startIndex;
222             }
223         }
224     }
225 }
226