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
17package com.android.mail.utils;
18
19import android.graphics.Color;
20import android.graphics.Typeface;
21import android.text.SpannableStringBuilder;
22import android.text.Spanned;
23import android.text.style.AbsoluteSizeSpan;
24import android.text.style.ForegroundColorSpan;
25import android.text.style.QuoteSpan;
26import android.text.style.StyleSpan;
27import android.text.style.TypefaceSpan;
28import android.text.style.URLSpan;
29import android.text.style.UnderlineSpan;
30
31import com.android.mail.analytics.AnalyticsTimer;
32import com.google.android.mail.common.base.CharMatcher;
33import com.google.android.mail.common.html.parser.HTML;
34import com.google.android.mail.common.html.parser.HTML4;
35import com.google.android.mail.common.html.parser.HtmlDocument;
36import com.google.android.mail.common.html.parser.HtmlTree;
37import com.google.common.collect.Lists;
38
39import java.util.LinkedList;
40
41public 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     */
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
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
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         */
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         */
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         */
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
206        public int getPlainTextLength() {
207            return mBuilder.length();
208        }
209
210        @Override
211        public Spanned getObject() {
212            return mBuilder;
213        }
214
215        private static class TagWrapper {
216            final HtmlDocument.Tag tag;
217            final int startIndex;
218
219            TagWrapper(HtmlDocument.Tag tag, int startIndex) {
220                this.tag = tag;
221                this.startIndex = startIndex;
222            }
223        }
224    }
225}
226