Implementing Rich Text Rendering on Android with HtmlSpanner
This article explains how to render rich, styled text in an Android OCS player by evaluating open‑source libraries, selecting HtmlSpanner, and detailing HTML tag support, CSS parsing, multi‑font handling, custom font loading, common pitfalls, and future improvement ideas.
Background
OCS (Open Courseware System) needs to render rich text (colors, bold, superscript, subscript, underline, fonts, line‑spacing, animations) on Web, iOS and Android. Using standard HTML for rendering provides cross‑platform support with low cost.
Library Survey and Choice
Android’s native HTML parsing relies on the third‑party org.ccil.cowan.tagsoup library, which only supports a subset of tags and does not handle CSS. Three open‑source libraries were evaluated:
AndroidCoreText – supports mixed image‑text and uses Android’s native tag set, but lacks CSS parsing.
XRichText – renders HTML directly, allows custom links and image downloaders, but also lacks CSS parsing.
HtmlSpanner – converts HTML to Android SpannableString and supports CSS (partial), requiring some adaptation.
HtmlSpanner was selected as the basis for rich‑text parsing.
Rich‑Text Parsing Components
HTML tag support
CSS style support
Multi‑font support
HTML Tag Support
HtmlSpanner extends Android’s native tag handling, so all tags supported by the platform work out of the box. Unhandled tags can be registered via HtmlSpanner.registerBuiltInHandlers(). Special characters are normalized in the TextUtil utility class.
CSS Style Support
HtmlSpanner uses the CssParser library (source: https://github.com/corgrath/osbcp-css-parser) to parse CSS. The core parsing occurs in CSSCompiler.java, which converts CSS text into Rule objects:
private void parseCSSFromText(String text, SpanStack spanStack) {
try {
for (Rule rule : CSSParser.parse(text)) {
spanStack.registerCompiledRule(CSSCompiler.compile(rule, getSpanner()));
}
} catch (Exception e) {
Log.e("StyleNodeHandler", "Unparseable CSS definition", e);
}
}Custom CSS properties can be added by extending the compiler, for example handling font-family:
public static StyleUpdater getStyleUpdater(final String key, final String value) {
if ("font-family".equals(key)) {
// parse font family and apply
return new StyleUpdater() {
@Override
public Style updateStyle(Style style, HtmlSpanner spanner) {
FontFamily family = spanner.getFont(value);
return style.setFontFamily(family);
}
};
}
// other properties …
return null;
}Multi‑Font Support
Course content often requires many fonts (Chinese, Japanese, Korean, decorative). Loading full font files (~20 MB each) is memory‑intensive, and Android can apply only one font per TextView. The solution consists of three steps:
Generate a trimmed font file that contains only the required glyphs.
Register the trimmed font with HtmlSpanner’s SystemFontResolver.
Render HTML that references the custom font.
Step 1 – Create a Trimmed Font
The open‑source sfntly tool (download: https://code.google.com/p/sfntly/) can extract only the needed characters from a full TTF file:
java -jar sfnttool.jar -s "characters" original.ttf new.ttfThis reduces the file size from megabytes to a few hundred kilobytes, depending on the character set.
Step 2 – Register Custom Fonts
Android ships with three base fonts (monospace, sans, serif) and four style variants. SystemFontResolver creates four FontFamily objects:
public SystemFontResolver() {
this.defaultFont = new FontFamily("default", Typeface.DEFAULT);
this.serifFont = new FontFamily("serif", Typeface.SERIF);
this.sansSerifFont = new FontFamily("sans-serif", Typeface.SANS_SERIF);
this.monoSpaceFont = new FontFamily("monospace", Typeface.MONOSPACE);
}To load external fonts dynamically, extend the resolver:
@Override
public void applyFont(String fontName, String path) {
if (!TextUtils.isEmpty(fontName) && !TextUtils.isEmpty(path)) {
File file = new File(path);
if (file.exists()) {
fontName = fontName.toLowerCase();
customFontMap.put(fontName, new FontFamily(fontName, Typeface.createFromFile(file)));
}
}
}Step 3 – Display Text with Custom Font
// Dynamically load font
HtmlSpannerHelper.getInstance()
.getSpanner()
.getFontResolver()
.applyFont(getAssets(), "ArtFont1", "fonts/a1.ttf");
TextView tv = findViewById(R.id.txt2);
String html = "<span style=\"font-family:'ArtFont1';font-size:60pt;color:#333333;\">Sample Text</span>";
HtmlSpannerHelper.getInstance().setText(tv, html);HtmlSpanner Internals
HtmlSpanner registers many TagNodeHandler implementations. Each handler translates an HTML tag into an Android span via the HtmlCleaner parser, ultimately producing a Spannable that a TextView can render. Example registration:
registerHandler("span", wrap(new StyledTextHandler()));Common Pitfalls
1. Inconsistent Line Height Across SDK Versions
Older Android versions render line height differently. A custom LineHeightSpan can normalize the height:
@Override
public void chooseHeight(CharSequence text, int start, int end, int spanstartv, int v, Paint.FontMetricsInt fm) {
int extra = 0;
if (height > 0) {
extra = Math.round(height - (fm.descent - fm.ascent));
} else if (spacingmult > 0) {
extra = Math.round((fm.descent - fm.ascent) * (spacingmult - 1));
}
if (Build.VERSION.SDK_INT >= 23) {
if (start == ((Spanned) text).getSpanStart(this)) {
fm.descent += extra;
fm.bottom = fm.descent;
}
} else {
fm.descent += extra;
fm.bottom = fm.descent;
}
}2. Font Size Adaptation
Font size handling occurs in FontHandler and StyledTextHandler. When the device orientation changes, the view is redrawn and the size is recomputed. Example handling of bottom margin as an extra newline:
if (useStyle.getMarginBottom() != null) {
StyleValue sv = useStyle.getMarginBottom();
if (sv.getUnit() == StyleValue.Unit.PX && sv.getIntValue() > 0) {
appendNewLine(builder);
stack.pushSpan(new VerticalMarginSpan(sv.getIntValue()), builder.length() - 1, builder.length());
}
}3. High Resource Consumption
Custom fonts remain in memory; they should be released when no longer needed.
Rich‑text parsing and image loading are time‑consuming. Using a static Handler avoids memory leaks.
public void setText(final TextView tv, final String text) {
new Thread(new Runnable() {
@Override
public void run() {
if (tv != null && text != null) {
Spannable sp = mSpannable.fromHtml(text);
Message msg = mHandler.obtainMessage();
msg.obj = new SpannableMessage(tv, sp);
mHandler.sendMessage(msg);
}
}
}).start();
}Future Improvements
Replace the heavy HTMLCleaner library with the built‑in javax.xml.parsers to reduce method count.
Integrate an image loading library such as Picasso for more efficient image handling.
Improve style merging so that multiple style attributes on a single tag are combined rather than only the first being applied.
Hujiang Technology
We focus on the real-world challenges developers face, delivering authentic, practical content and a direct platform for technical networking among developers.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
