/*
* Copyright (C) 2015-2017 Emanuel Moecklin
*
* 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.onegravity.rteditor.converter;
import android.text.Spanned;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.BackgroundColorSpan;
import android.text.style.CharacterStyle;
import android.text.style.ForegroundColorSpan;
import android.text.style.ParagraphStyle;
import android.text.style.StrikethroughSpan;
import android.text.style.SubscriptSpan;
import android.text.style.SuperscriptSpan;
import android.text.style.URLSpan;
import com.onegravity.rteditor.spans.UnderlineSpan;
import com.onegravity.rteditor.api.format.RTFormat;
import com.onegravity.rteditor.api.format.RTHtml;
import com.onegravity.rteditor.api.media.RTAudio;
import com.onegravity.rteditor.api.media.RTImage;
import com.onegravity.rteditor.api.media.RTVideo;
import com.onegravity.rteditor.converter.tagsoup.util.StringEscapeUtils;
import com.onegravity.rteditor.spans.AudioSpan;
import com.onegravity.rteditor.spans.BoldSpan;
import com.onegravity.rteditor.spans.TypefaceSpan;
import com.onegravity.rteditor.spans.ImageSpan;
import com.onegravity.rteditor.spans.ItalicSpan;
import com.onegravity.rteditor.spans.LinkSpan;
import com.onegravity.rteditor.spans.VideoSpan;
import com.onegravity.rteditor.utils.Helper;
import com.onegravity.rteditor.utils.Paragraph;
import com.onegravity.rteditor.utils.RTLayout;
import com.onegravity.rteditor.utils.Selection;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.SortedSet;
import java.util.Stack;
import java.util.TreeSet;
/**
* Converts Spanned text to html
*/
public class ConverterSpannedToHtml {
private static final String BR = "<br/>\n";
private static final String LT = "<";
private static final String GT = ">";
private static final String AMP = "&";
private static final String NBSP = " ";
private StringBuilder mOut;
private Spanned mText;
private RTFormat mRTFormat;
private List<RTImage> mImages;
private Stack<AccumulatedParagraphStyle> mParagraphStyles = new Stack<AccumulatedParagraphStyle>();
/**
* Converts a spanned text to HTML
*/
public RTHtml<RTImage, RTAudio, RTVideo> convert(final Spanned text, RTFormat.Html rtFormat) {
mText = text;
mRTFormat = rtFormat;
mOut = new StringBuilder();
mImages = new ArrayList<RTImage>();
mParagraphStyles.clear();
// convert paragraphs
convertParagraphs();
return new RTHtml<RTImage, RTAudio, RTVideo>(rtFormat, mOut.toString(), mImages);
}
// ****************************************** Process Paragraphs *******************************************
private void convertParagraphs() {
RTLayout rtLayout = new RTLayout(mText);
// a manual for loop is faster than the for-each loop for an ArrayList:
// see https://developer.android.com/training/articles/perf-tips.html#Loops
ArrayList<Paragraph> paragraphs = rtLayout.getParagraphs();
for (int i = 0, size = paragraphs.size(); i < size; i++) {
Paragraph paragraph = paragraphs.get(i);
// retrieve all spans for this paragraph
Set<SingleParagraphStyle> styles = getParagraphStyles(mText, paragraph);
// get the alignment span if there is any
ParagraphType alignmentType = null;
for (SingleParagraphStyle style : styles) {
if (style.getType().isAlignment()) {
alignmentType = style.getType();
break;
}
}
/*
* start tag: bullet points, numbering and indentation
*/
int newIndent = 0;
ParagraphType newType = ParagraphType.NONE;
for (SingleParagraphStyle style : styles) {
newIndent += style.getIndentation();
ParagraphType type = style.getType();
newType = type.isBullet() ? ParagraphType.BULLET :
type.isNumbering() ? ParagraphType.NUMBERING :
type.isIndentation() && newType.isUndefined() ? ParagraphType.INDENTATION_UL : newType;
}
// process leading margin style
processLeadingMarginStyle(new AccumulatedParagraphStyle(newType, newIndent, 0));
// add start list tag
mOut.append(newType.getListStartTag());
/*
* start tag: alignment (left, center, right)
*/
if (alignmentType != null) {
mOut.append(alignmentType.getStartTag());
}
/*
* Convert the plain text
*/
withinParagraph(mText, paragraph.start(), paragraph.end());
/*
* end tag: alignment (left, center, right)
*/
if (alignmentType != null) {
removeTrailingLineBreak(alignmentType);
mOut.append(alignmentType.getEndTag());
}
// add end list tag
removeTrailingLineBreak(newType);
mOut.append(newType.getListEndTag());
}
/*
* end tag: bullet points and indentation
*/
while (!mParagraphStyles.isEmpty()) {
removeParagraph();
}
}
private void removeTrailingLineBreak(ParagraphType type) {
if (type.endTagAddsLineBreak() && mOut.length() >= BR.length()) {
int start = mOut.length() - BR.length();
int end = mOut.length();
if (mOut.subSequence(start, end).equals(BR)) {
mOut.delete(start, end);
}
}
}
private Set<SingleParagraphStyle> getParagraphStyles(final Spanned text, Selection selection) {
Set<SingleParagraphStyle> styles = new HashSet<SingleParagraphStyle>();
for (ParagraphStyle style : text.getSpans(selection.start(), selection.end(), ParagraphStyle.class)) {
ParagraphType type = ParagraphType.getInstance(style);
if (type != null) {
styles.add(new SingleParagraphStyle(type, style));
}
}
return styles;
}
private void processLeadingMarginStyle(AccumulatedParagraphStyle newStyle) {
int currentIndent = 0;
ParagraphType currentType = ParagraphType.NONE;
if (!mParagraphStyles.isEmpty()) {
AccumulatedParagraphStyle currentStyle = mParagraphStyles.peek();
currentIndent = currentStyle.getAbsoluteIndent();
currentType = currentStyle.getType();
}
if (newStyle.getAbsoluteIndent() > currentIndent) {
newStyle.setRelativeIndent(newStyle.getAbsoluteIndent() - currentIndent);
addParagraph(newStyle);
} else if (newStyle.getAbsoluteIndent() < currentIndent) {
removeParagraph();
processLeadingMarginStyle(newStyle);
} else if (newStyle.getType() != currentType) {
newStyle.setRelativeIndent(removeParagraph());
addParagraph(newStyle);
}
}
private int removeParagraph() {
if (!mParagraphStyles.isEmpty()) {
AccumulatedParagraphStyle style = mParagraphStyles.pop();
String tag = style.getType().getEndTag();
int indent = style.getRelativeIndent();
for (int i = 0; i < indent; i++) {
mOut.append(tag);
}
return style.getRelativeIndent();
}
return 0;
}
private void addParagraph(AccumulatedParagraphStyle style) {
String tag = style.getType().getStartTag();
int indent = style.getRelativeIndent();
for (int i = 0; i < indent; i++) {
mOut.append(tag);
}
mParagraphStyles.push(style);
}
// ****************************************** Process Text *******************************************
/**
* Convert a spanned text within a paragraph
*/
private void withinParagraph(final Spanned text, int start, int end) {
// create sorted set of CharacterStyles
SortedSet<CharacterStyle> sortedSpans = new TreeSet<CharacterStyle>(new Comparator<CharacterStyle>() {
@Override
public int compare(CharacterStyle s1, CharacterStyle s2) {
int start1 = text.getSpanStart(s1);
int start2 = text.getSpanStart(s2);
if (start1 != start2)
return start1 - start2; // span which starts first comes first
int end1 = text.getSpanEnd(s1);
int end2 = text.getSpanEnd(s2);
if (end1 != end2) return end2 - end1; // longer span comes first
// if the paragraphs have the same span [start, end] we compare their name
// compare the name only because local + anonymous classes have no canonical name
return s1.getClass().getName().compareTo(s2.getClass().getName());
}
});
List<CharacterStyle> spanList = Arrays.asList(text.getSpans(start, end, CharacterStyle.class));
sortedSpans.addAll(spanList);
// process paragraphs/divs
convertText(text, start, end, sortedSpans);
}
private void convertText(Spanned text, int start, int end, SortedSet<CharacterStyle> spans) {
while (start < end) {
// get first CharacterStyle
CharacterStyle span = spans.isEmpty() ? null : spans.first();
int spanStart = span == null ? Integer.MAX_VALUE : text.getSpanStart(span);
int spanEnd = span == null ? Integer.MAX_VALUE : text.getSpanEnd(span);
if (start < spanStart) {
// no paragraph, just plain text
escape(text, start, Math.min(end, spanStart));
start = spanStart;
} else {
// CharacterStyle found
spans.remove(span);
if (handleStartTag(span)) {
convertText(text, Math.max(spanStart, start), Math.min(spanEnd, end), spans);
}
handleEndTag(span);
start = spanEnd;
}
}
}
/**
* @return True if the text between the tags should be converted too, False if it should be skipped (ImageSpan e.g.)
*/
private boolean handleStartTag(CharacterStyle style) {
if (style instanceof BoldSpan) {
mOut.append("<b>");
} else if (style instanceof ItalicSpan) {
mOut.append("<i>");
} else if (style instanceof UnderlineSpan) {
mOut.append("<u>");
} else if (style instanceof SuperscriptSpan) {
mOut.append("<sup>");
} else if (style instanceof SubscriptSpan) {
mOut.append("<sub>");
} else if (style instanceof StrikethroughSpan) {
mOut.append("<strike>");
}
/* Examples for fonts styles:
<font face="verdana" style="font-size:25px;background-color:#00ff00;color:#ff0000">This is heading 1</font>
<font face="DroidSans" style="font-size:50px;background-color:#0000FF;color:#FFFF00">This is heading 2</font>
*/
else if (style instanceof TypefaceSpan) {
mOut.append("<font face=\"");
String fontName = ((TypefaceSpan) style).getValue().getName();
mOut.append(StringEscapeUtils.escapeHtml4(fontName));
mOut.append("\">");
} else if (style instanceof AbsoluteSizeSpan) {
mOut.append("<font style=\"font-size:");
int size = ((AbsoluteSizeSpan) style).getSize();
size = Helper.convertSpToPx(size);
mOut.append(size);
mOut.append("px\">");
} else if (style instanceof ForegroundColorSpan) {
mOut.append("<font style=\"color:#");
String color = Integer.toHexString(((ForegroundColorSpan) style).getForegroundColor() + 0x01000000);
while (color.length() < 6) {
color = "0" + color;
}
mOut.append(color);
mOut.append("\">");
} else if (style instanceof BackgroundColorSpan) {
mOut.append("<font style=\"background-color:#");
String color = Integer.toHexString(((BackgroundColorSpan) style).getBackgroundColor() + 0x01000000);
while (color.length() < 6) {
color = "0" + color;
}
mOut.append(color);
mOut.append("\">");
} else if (style instanceof LinkSpan) {
mOut.append("<a href=\"");
mOut.append(((URLSpan) style).getURL());
mOut.append("\">");
} else if (style instanceof ImageSpan) {
ImageSpan span = ((ImageSpan) style);
RTImage image = span.getImage();
mImages.add(image);
String filePath = image.getFilePath(mRTFormat);
mOut.append("<img src=\"" + filePath + "\">");
return false; // don't output the dummy character underlying the image.
} else if (style instanceof AudioSpan) {
AudioSpan span = ((AudioSpan) style);
RTAudio audio = span.getAudio();
String filePath = audio.getFilePath(mRTFormat);
mOut.append("<embed src=\"" + filePath + "\">");
return false; // don't output the dummy character underlying the audio file.
} else if (style instanceof VideoSpan) {
VideoSpan span = ((VideoSpan) style);
RTVideo video = span.getVideo();
String filePath = video.getFilePath(mRTFormat);
mOut.append("<video controls src=\"" + filePath + "\">");
return false; // don't output the dummy character underlying the video.
}
return true;
}
private void handleEndTag(CharacterStyle style) {
if (style instanceof URLSpan) {
mOut.append("</a>");
} else if (style instanceof TypefaceSpan) {
mOut.append("</font>");
} else if (style instanceof ForegroundColorSpan) {
mOut.append("</font>");
} else if (style instanceof BackgroundColorSpan) {
mOut.append("</font>");
} else if (style instanceof AbsoluteSizeSpan) {
mOut.append("</font>");
} else if (style instanceof StrikethroughSpan) {
mOut.append("</strike>");
} else if (style instanceof SubscriptSpan) {
mOut.append("</sub>");
} else if (style instanceof SuperscriptSpan) {
mOut.append("</sup>");
} else if (style instanceof UnderlineSpan) {
mOut.append("</u>");
} else if (style instanceof BoldSpan) {
mOut.append("</b>");
} else if (style instanceof ItalicSpan) {
mOut.append("</i>");
}
}
/**
* Escape plain text parts: <, >, &, Space --> ^lt;, > etc.
*/
private void escape(CharSequence text, int start, int end) {
for (int i = start; i < end; i++) {
char c = text.charAt(i);
if (c == '\n') {
mOut.append(BR);
} else if (c == '<') {
mOut.append(LT);
} else if (c == '>') {
mOut.append(GT);
} else if (c == '&') {
mOut.append(AMP);
} else if (c == ' ') {
while (i + 1 < end && text.charAt(i + 1) == ' ') {
mOut.append(NBSP);
i++;
}
mOut.append(' ');
}
// removed the c > 0x7E check to leave emoji unaltered
else if (/*c > 0x7E || */c < ' ') {
mOut.append("" + ((int) c) + ";");
} else {
mOut.append(c);
}
}
}
}