Repository: cgeo/cgeo (Java)

Issue:
Cache description: syntax highlighting for HTML Source view
Ticket #719845
cgeo version 2024.10.27

In c:geo's description style option "HTML Source", the HTML is displayed unformatted and without any color coding.

A user on support wishes to at least add color coding for HTML comments (e.g. displaying HTML comments like `<!-- this is a comment -->` in a different color eg blue).  

A more throughout immpllementation would also add some formatting (means: inserting line feeds and intends so that HTML is better readable) and color coding (e.g. also mark tag names, attribute names and such in different colors). Much like modern browsers do it in their inspection views - maybe a tad less complex :-)

![image](https://github.com/user-attachments/assets/e9785817-2e6a-4dc0-8380-fa8172526966)

![image](https://github.com/user-attachments/assets/34358504-2d03-48e8-a03f-3e7a3e7b1259)



Discussion: The following links describe an interesting approach: Instead of implementing our own highlighting or integrating a separate Java library for it, use a well-established JS syntax highlighting library in conjunction with built-in WebView component:

- https://hossainkhan.medium.com/source-code-syntax-highlighting-on-android-taking-full-control-b704fd4bd8ee
- https://github.com/hossain-khan/android-syntax-highlighter

> The following links describe an interesting approach: Instead of implementing our own highlighting or integrating a separate Java library for it, use a well-established JS syntax highlighting library in conjunction with built-in WebView component:
> 
> * https://hossainkhan.medium.com/source-code-syntax-highlighting-on-android-taking-full-control-b704fd4bd8ee
> * https://github.com/hossain-khan/android-syntax-highlighter

Usage of "WebView" is the no-no-trigger for me personally here. I don't have good experience with this one  :-)
> Usage of "WebView" is the no-no-trigger for me personally here. I don't have good experience with this one :-)

:smile: 

Ok, just was an idea for not having to integrate yet another library if we want to pursue syntax highlighting. But there may be lightweight alternatives (hopefully).
Implemented with PR #16315 

While looking for libs to help with this I realized that c:geo already incorporates [JSoup](https://jsoup.org/) for other purposes, so it was the logical choice not to use yet another second lib. Turns out JSoup is great in doing the HTML cleaning and the pretty printing, but lacks support to add custom colors easily to pretty-printed HTML. So that part had to be done manually.



Fix (patch):
diff --git a/main/src/androidTest/java/cgeo/geocaching/downloader/DownloaderTest.java b/main/src/androidTest/java/cgeo/geocaching/downloader/DownloaderTest.java
index 2ddc1102dce..bec93eae8cf 100644
--- a/main/src/androidTest/java/cgeo/geocaching/downloader/DownloaderTest.java
+++ b/main/src/androidTest/java/cgeo/geocaching/downloader/DownloaderTest.java
@@ -58,7 +58,7 @@ public void testMapsforge() {
         assertThat(list.get(0).isDir()).isTrue();
 
         // number of dirs found
-        assertThat(count(list, true)).isEqualTo(7);
+        assertThat(count(list, true)).isGreaterThanOrEqualTo(7);
 
         // number of non-dirs found
         assertThat(count(list, false)).isBetween(49, 53);
diff --git a/main/src/main/java/cgeo/geocaching/CacheDetailActivity.java b/main/src/main/java/cgeo/geocaching/CacheDetailActivity.java
index aab4c2e8deb..48dd8b03e4d 100644
--- a/main/src/main/java/cgeo/geocaching/CacheDetailActivity.java
+++ b/main/src/main/java/cgeo/geocaching/CacheDetailActivity.java
@@ -92,6 +92,7 @@
 import cgeo.geocaching.utils.CheckerUtils;
 import cgeo.geocaching.utils.ClipboardUtils;
 import cgeo.geocaching.utils.CollectionStream;
+import cgeo.geocaching.utils.CommonUtils;
 import cgeo.geocaching.utils.CryptUtils;
 import cgeo.geocaching.utils.DisposableHandler;
 import cgeo.geocaching.utils.EmojiUtils;
@@ -194,17 +195,10 @@ public class CacheDetailActivity extends TabbedViewPagerActivity
     private static final String EXTRA_FORCE_WAYPOINTSPAGE = "cgeo.geocaching.extra.cachedetail.forceWaypointsPage";
     private static final String EXTRA_EDIT_PERSONALNOTE = "cgeo.geocaching.extra.cachedetail.editPersonalNote";
 
-    /**
-     * Minimal contrast ratio. If description:background contrast ratio is less than this value
-     * for some string, foreground color will be removed and gray background will be used
-     * in order to highlight the string
-     *
-     * @see <a href="https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html">W3 Minimum Contrast</a>
-     **/
-    private static final float CONTRAST_THRESHOLD = 4.5f;
-
     public static final String STATE_PAGE_INDEX = "cgeo.geocaching.pageIndex";
     public static final String STATE_IMAGE_GALLERY = "cgeo.geocaching.imageGallery";
+    public static final String STATE_DESCRIPTION_STYLE = "cgeo.geocaching.descriptionStyle";
+
 
     // Store Geocode here, as 'cache' is loaded Async.
     private String geocode;
@@ -355,6 +349,9 @@ public void onCreate(final Bundle savedInstanceState) {
                 savedInstanceState != null ?
                         savedInstanceState.getLong(STATE_PAGE_INDEX, Page.DETAILS.id) :
                         Settings.isOpenLastDetailsPage() ? Settings.getLastDetailsPage() : Page.DETAILS.id;
+        if (savedInstanceState != null && savedInstanceState.containsKey(STATE_DESCRIPTION_STYLE)) {
+            this.descriptionStyle = CommonUtils.intToEnum(HtmlStyle.class, savedInstanceState.getInt(STATE_DESCRIPTION_STYLE), this.descriptionStyle);
+        }
 
         imageGalleryState = savedInstanceState == null ? null : savedInstanceState.getBundle(STATE_IMAGE_GALLERY);
 
@@ -415,6 +412,7 @@ public void onSaveInstanceState(@NonNull final Bundle outState) {
         super.onSaveInstanceState(outState);
         outState.putLong(STATE_PAGE_INDEX, getCurrentPageId());
         outState.putBundle(STATE_IMAGE_GALLERY, imageGallery == null ? null : imageGallery.getState());
+        outState.putInt(STATE_DESCRIPTION_STYLE, CommonUtils.enumToInt(this.descriptionStyle));
     }
 
     private void startOrStopGeoDataListener(final boolean initial) {
@@ -1909,7 +1907,6 @@ private static Pair<CharSequence, Boolean> createDescriptionContent(final Activi
                     handleImageClick(activity, cache, description);
                     //display various fixes
                     HtmlUtils.fixRelativeLinks(description, ConnectorFactory.getConnector(cache).getHostUrl() + "/");
-                    addColorContrast(activity, description);
                     fixOldGeocheckerLink(activity, cache, description);
                 }
 
@@ -1963,11 +1960,6 @@ private static void fixOldGeocheckerLink(final Activity activity, final Geocache
         }
     }
 
-    private static void addColorContrast(final Context context, final Spannable spannable) {
-        final int bgColor = context.getResources().getColor(R.color.colorBackground);
-        HtmlUtils.addColorContrast(spannable, bgColor, CONTRAST_THRESHOLD);
-    }
-
     protected void saveAndNotify() {
         saveAndNotify(CacheDetailActivity.this, cache);
     }
diff --git a/main/src/main/java/cgeo/geocaching/network/HtmlImage.java b/main/src/main/java/cgeo/geocaching/network/HtmlImage.java
index ecb45f34a4c..3ee739b9ebe 100644
--- a/main/src/main/java/cgeo/geocaching/network/HtmlImage.java
+++ b/main/src/main/java/cgeo/geocaching/network/HtmlImage.java
@@ -1,7 +1,6 @@
 package cgeo.geocaching.network;
 
 import cgeo.geocaching.CgeoApplication;
-import cgeo.geocaching.R;
 import cgeo.geocaching.connector.ConnectorFactory;
 import cgeo.geocaching.storage.ContentStorage;
 import cgeo.geocaching.storage.LocalStorage;
@@ -15,6 +14,7 @@
 import cgeo.geocaching.utils.MetadataUtils;
 import cgeo.geocaching.utils.RxUtils.ObservableCache;
 import cgeo.geocaching.utils.UriUtils;
+import cgeo.geocaching.utils.html.HtmlUtils;
 
 import android.content.res.Resources;
 import android.graphics.Bitmap;
@@ -341,7 +341,7 @@ private BitmapDrawable getErrorImage() {
 
     public static BitmapDrawable getErrorImage(final Resources resources, final boolean nonTransparent) {
         if (nonTransparent) {
-            return new BitmapDrawable(resources, BitmapFactory.decodeResource(resources, R.drawable.image_not_loaded));
+            return HtmlUtils.IMAGE_NOT_LOADED;
         }
         return ImageUtils.getTransparent1x1Drawable(resources);
     }
diff --git a/main/src/main/java/cgeo/geocaching/utils/TextUtils.java b/main/src/main/java/cgeo/geocaching/utils/TextUtils.java
index 74c3f404cde..cf751681393 100644
--- a/main/src/main/java/cgeo/geocaching/utils/TextUtils.java
+++ b/main/src/main/java/cgeo/geocaching/utils/TextUtils.java
@@ -635,7 +635,7 @@ public static CharSequence setSpan(final CharSequence cs, final Object span, fin
         return sp;
     }
 
-    public static String annotateSpans(final CharSequence cs, final Func1<Object, Pair<String, String>> annotator) {
+    public static String annotateSpans(final CharSequence cs, @Nullable final Func1<Object, Pair<String, String>> annotator) {
         if (!(cs instanceof Spanned) || cs.length() == 0) {
             return cs.toString();
         }
@@ -646,10 +646,11 @@ public static String annotateSpans(final CharSequence cs, final Func1<Object, Pa
 
         final SortedMap<Integer, String> data = new TreeMap<>((i1, i2) -> -i1.compareTo(i2));
         for (Object span : spans) {
+            final Pair<String, String> markers = annotator == null ? null : annotator.call(span);
             final int start = ((Spanned) cs).getSpanStart(span);
-            data.put(start, (data.containsKey(start) ? data.get(start) : "") + annotator.call(span).first);
+            data.put(start, (data.containsKey(start) ? data.get(start) : "") + (markers == null || markers.first == null ? "" : markers.first));
             final int end = ((Spanned) cs).getSpanEnd(span);
-            data.put(end, annotator.call(span).second + (data.containsKey(end) ? data.get(end) : ""));
+            data.put(end, (markers == null || markers.second == null ? "" : markers.second) + (data.containsKey(end) ? data.get(end) : ""));
         }
 
         String result = cs.toString();
diff --git a/main/src/main/java/cgeo/geocaching/utils/html/HtmlStyle.java b/main/src/main/java/cgeo/geocaching/utils/html/HtmlStyle.java
index 30eae2dc7ec..62ff5137eb8 100644
--- a/main/src/main/java/cgeo/geocaching/utils/html/HtmlStyle.java
+++ b/main/src/main/java/cgeo/geocaching/utils/html/HtmlStyle.java
@@ -1,45 +1,83 @@
 package cgeo.geocaching.utils.html;
 
+import cgeo.geocaching.CgeoApplication;
 import cgeo.geocaching.R;
+import cgeo.geocaching.settings.Settings;
 import cgeo.geocaching.utils.LocalizationUtils;
 import cgeo.geocaching.utils.Log;
+import cgeo.geocaching.utils.TextUtils;
+import cgeo.geocaching.utils.functions.Func4;
 
 import android.content.Context;
 import android.graphics.drawable.Drawable;
 import android.text.Spannable;
 import android.text.SpannableStringBuilder;
+import android.text.style.TypefaceSpan;
 import android.util.Pair;
 
+import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
 
 import java.util.function.Function;
 
-import kotlin.jvm.functions.Function3;
+import org.apache.commons.lang3.StringUtils;
 
 /** An enum providing different types of renderers for a HTML source into a SpannableString (suitable for a TextView) */
 public enum HtmlStyle {
 
-    DEFAULT(R.string.html_style_default, (ctx, text, imageGetter) -> HtmlUtils.renderHtml(text, imageGetter, false)),
-    MONOCHROME(R.string.html_style_monochrome, (ctx, text, imageGetter) -> HtmlUtils.renderHtml(text, imageGetter, true)),
-    HTML_SOURCE(R.string.html_style_source, (ctx, text, imageGetter) -> new Pair<>(new SpannableStringBuilder(text), false));
+    DEFAULT(R.string.html_style_default, false, true, (ctx, text, isLightSkin, imageGetter) -> HtmlUtils.renderHtml(text, imageGetter)),
+    DEFAULT_NO_CONTRAST_FIX(R.string.html_style_default_no_contrast_fix, false, false, (ctx, text, isLightSkin, imageGetter) -> HtmlUtils.renderHtml(text, imageGetter)),
+    MONOCHROME(R.string.html_style_monochrome, true, false, (ctx, text, isLightSkin, imageGetter) -> HtmlUtils.renderHtml(text, imageGetter)),
+    HTML_SOURCE_FORMATTED(R.string.html_style_source_formatted, false, false, (ctx, text, isLightSkin, imageGetter) -> new Pair<>(HtmlUtils.getFormattedHtml(text, true, true, isLightSkin), false)),
+    HTML_SOURCE(R.string.html_style_source, false, false, (ctx, text, isLightSkin, imageGetter) -> {
+        final Spannable span = new SpannableStringBuilder(text);
+        TextUtils.setSpan(span, new TypefaceSpan("monospace"));
+        return new Pair<>(span, false);
+    });
+
+    /**
+     * Minimal contrast ratio. If description:background contrast ratio is less than this value
+     * for some string, foreground color will be removed and gray background will be used
+     * in order to highlight the string
+     *
+     * @see <a href="https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html">W3 Minimum Contrast</a>
+     **/
+    private static final float CONTRAST_THRESHOLD = 4.5f;
 
     @StringRes
     private final int resId;
-    private final Function3<Context, String, Function<String, Drawable>, Pair<Spannable, Boolean>> renderer;
+    private final boolean monochrome;
+    private final boolean colorContrast;
+    private final Func4<Context, String, Boolean, Function<String, Drawable>, Pair<Spannable, Boolean>> renderer;
 
-    HtmlStyle(@StringRes final int resId, final Function3<Context, String, Function<String, Drawable>, Pair<Spannable, Boolean>> renderer) {
+    HtmlStyle(@StringRes final int resId, final boolean monochrome, final boolean colorContrast, final Func4<Context, String, Boolean, Function<String, Drawable>, Pair<Spannable, Boolean>> renderer) {
         this.resId = resId;
         this.renderer = renderer;
+        this.monochrome = monochrome;
+        this.colorContrast = colorContrast;
     }
 
     public String getTitle() {
         return LocalizationUtils.getString(this.resId);
     }
 
-    public Pair<Spannable, Boolean> render(final Context ctx, final String htmlText, final Function<String, Drawable> imageGetter) {
+    public Pair<Spannable, Boolean> render(@Nullable final Context context, @Nullable final String htmlText, @Nullable final Function<String, Drawable> imageGetter) {
+        if (StringUtils.isBlank(htmlText)) {
+            return new Pair<>(new SpannableStringBuilder(""), false);
+        }
+        final Context ctx = context == null ? CgeoApplication.getInstance() : context;
         try {
-            final Pair<Spannable, Boolean> result = this.renderer.invoke(ctx, htmlText, imageGetter);
+            final Function<String, Drawable> imageGetterToUse = imageGetter == null ? HtmlUtils.DUMMY_IMAGE_GETTER : imageGetter;
+            final boolean isLightSkin = Settings.isLightSkin(ctx);
+            final Pair<Spannable, Boolean> result = this.renderer.call(ctx, htmlText, isLightSkin, imageGetterToUse);
             if (result != null && result.first != null) {
+                if (monochrome) {
+                    HtmlUtils.makeMonochrome(result.first);
+                }
+                if (colorContrast) {
+                    HtmlUtils.addColorContrast(result.first, ctx.getResources().getColor(R.color.colorBackground), CONTRAST_THRESHOLD);
+                }
+
                 return result;
             }
             return new Pair<>(new SpannableStringBuilder().append("Error:empty result"), true);
diff --git a/main/src/main/java/cgeo/geocaching/utils/html/HtmlUtils.java b/main/src/main/java/cgeo/geocaching/utils/html/HtmlUtils.java
index 1e18cd12ba8..1141c5264ec 100644
--- a/main/src/main/java/cgeo/geocaching/utils/html/HtmlUtils.java
+++ b/main/src/main/java/cgeo/geocaching/utils/html/HtmlUtils.java
@@ -1,10 +1,18 @@
 package cgeo.geocaching.utils.html;
 
+import cgeo.geocaching.CgeoApplication;
+import cgeo.geocaching.R;
 import cgeo.geocaching.utils.ColorUtils;
+import cgeo.geocaching.utils.TextUtils;
 
+import android.graphics.BitmapFactory;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
 import android.net.Uri;
 import android.text.Spannable;
+import android.text.SpannableString;
 import android.text.SpannableStringBuilder;
 import android.text.Spanned;
 import android.text.TextPaint;
@@ -12,11 +20,15 @@
 import android.text.style.ClickableSpan;
 import android.text.style.ForegroundColorSpan;
 import android.text.style.ImageSpan;
+import android.text.style.StyleSpan;
+import android.text.style.TypefaceSpan;
 import android.text.style.URLSpan;
 import android.util.Pair;
 import android.view.View;
 
+import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.core.text.HtmlCompat;
 
 import java.util.ArrayList;
@@ -27,9 +39,22 @@
 import java.util.function.Function;
 
 import org.apache.commons.lang3.StringUtils;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
 
 public final class HtmlUtils {
 
+    public static final BitmapDrawable IMAGE_NOT_LOADED = CgeoApplication.getInstance() == null ? null :
+            new BitmapDrawable(CgeoApplication.getInstance().getResources(), BitmapFactory.decodeResource(CgeoApplication.getInstance().getResources(), R.drawable.image_not_loaded));
+
+    public static final Function<String, Drawable> DUMMY_IMAGE_GETTER = url -> IMAGE_NOT_LOADED;
+
+    @ColorInt public static final int COLOR_PURPLE = 0xFF800080;
+    @ColorInt public static final int COLOR_BROWN = 0xFFA52A2A;
+    @ColorInt public static final int COLOR_LTBLUE = 0xFFADD8E6;
+    @ColorInt public static final int COLOR_REDDISH = 0xFFFF5733;
+
+
     private HtmlUtils() {
         // utility class
     }
@@ -200,19 +225,207 @@ public static <T> void replaceSpans(final Spannable spannable, final Class<T> ty
     }
 
     /** makes text in spannable of monochrome color (by removing all foreground and background colors) */
-    private static void makeMonochrome(final Spannable spannable) {
+    public static void makeMonochrome(final Spannable spannable) {
         replaceSpans(spannable, ForegroundColorSpan.class, null);
         replaceSpans(spannable, BackgroundColorSpan.class, null);
     }
 
-    public static Pair<Spannable, Boolean> renderHtml(final String html, final Function<String, Drawable> imageGetter, final boolean makeMonochrome) {
+    public static Pair<Spannable, Boolean> renderHtml(final String html, final Function<String, Drawable> imageGetter) {
         final UnknownTagsHandler unknownTagsHandler = new UnknownTagsHandler();
         final SpannableStringBuilder description = new SpannableStringBuilder(HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY, imageGetter::apply, unknownTagsHandler));
-        if (makeMonochrome) {
-            makeMonochrome(description);
-        }
         return new Pair<>(description, unknownTagsHandler.isProblematicDetected());
 
     }
 
+    /** returns formatted HTML data (syntax-corrected as well as pretty-printed and colorized according to input parameters) */
+    public static Spannable getFormattedHtml(final String html, final boolean prettyPrint, final boolean colorize, final boolean isLightMode) {
+        final Document document = Jsoup.parse(html);
+        if (prettyPrint) {
+            document.outputSettings().prettyPrint(true).indentAmount(2).outline(true);
+        } else {
+            document.outputSettings().prettyPrint(false).outline(false);
+        }
+        if (!colorize) {
+            return new SpannableString(document.body().html());
+        }
+        final HtmlTokenAppender ca = new HtmlTokenAppender();
+        document.body().html(ca);
+        final SpannableStringBuilder ssb = new SpannableStringBuilder();
+        ssb.append(ca.textBuilder.toString());
+        for (TokenData sd : ca.foundTokens) {
+            sd.applyTo(ssb, isLightMode);
+        }
+        TextUtils.setSpan(ssb, new TypefaceSpan("monospace"));
+        return ssb;
+    }
+
+    /** Data for a found HTML token */
+    private static class TokenData {
+        public final int start;
+        public final int end;
+        public final Token token;
+
+        TokenData(final int start, final int end, final Token span) {
+            this.start = start;
+            this.end = end;
+            this.token = span;
+        }
+
+        public void applyTo(final Spannable span, final boolean isLightMode) {
+            this.token.setSpan(span, start, end, isLightMode);
+        }
+    }
+
+    /** HTML tokens relevant for formatting */
+    private enum Token {
+        TAG(Typeface.BOLD, Color.BLUE, COLOR_PURPLE),
+        TAG_END(Typeface.BOLD, Color.BLUE, COLOR_PURPLE),
+        COMMENT(Typeface.NORMAL, Color.GREEN, Color.GREEN),
+        ATTRIBUTE(Typeface.NORMAL, COLOR_LTBLUE, COLOR_BROWN),
+        ATTRIBUTE_END(Typeface.NORMAL, COLOR_LTBLUE, COLOR_BROWN),
+        ATTRIBUTE_VALUE(Typeface.NORMAL, COLOR_REDDISH, Color.BLUE);
+
+        private final int style;
+        private final int colorDark;
+        private final int colorLight;
+
+        Token(final int style, final int colorDark, final int colorLight) {
+            this.style = style;
+            this.colorDark = colorDark;
+            this.colorLight = colorLight;
+        }
+
+        void setSpan(final Spannable span, final int start, final int end, final boolean isLightMode) {
+            final int color = isLightMode ? this.colorLight : this.colorDark;
+            if (color != 0) {
+                span.setSpan(new ForegroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+            }
+            if (this.style >= Typeface.NORMAL) {
+                span.setSpan(new StyleSpan(this.style), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+            }
+        }
+    }
+
+    /** Appender which scans and tokenizes certain HTML tokens (for later formatting) */
+    private static class HtmlTokenAppender implements Appendable {
+
+        public final StringBuilder textBuilder = new StringBuilder();
+        public final List<TokenData> foundTokens = new ArrayList<>();
+
+        private int tokenStart = -1;
+        private Token token = null;
+
+        private char previousChar = ' ';
+        private char previousPreviousChar = ' ';
+
+        @NonNull
+        @Override
+        public Appendable append(@Nullable final CharSequence csq) {
+            if (csq != null) {
+                final int len = textBuilder.length();
+                final int csqLen = csq.length();
+                for (int i = 0; i < csqLen; i++) {
+                    scan(csq.charAt(i), len + i);
+                }
+            }
+            textBuilder.append(csq);
+            return this;
+        }
+
+        @NonNull
+        @Override
+        public Appendable append(@Nullable final CharSequence csq, final int start, final int end) {
+            if (csq != null) {
+                final int len = textBuilder.length();
+                for (int i = start; i < end; i++) {
+                    scan(csq.charAt(i), len + i);
+                }
+            }
+            textBuilder.append(csq, start, end);
+            return this;
+        }
+
+        @NonNull
+        @Override
+        public Appendable append(final char c) {
+            scan(c, textBuilder.length());
+            textBuilder.append(c);
+            return this;
+        }
+
+        private void scan(final char c, final int pos) {
+            switch (c) {
+                case '<':
+                    //is considere normal char inside comments and attribute values
+                    if (!tokenOneOf(Token.ATTRIBUTE_VALUE, Token.COMMENT)) {
+                        startToken(Token.TAG, pos);
+                    }
+                    break;
+                case '!':
+                    if (token == Token.TAG && previousChar == '<') {
+                        token = Token.COMMENT;
+                    }
+                    break;
+                case '/':
+                    if (tokenOneOf(Token.ATTRIBUTE, Token.ATTRIBUTE_END)) {
+                        endToken(pos);
+                        startToken(Token.TAG_END, pos);
+                    }
+                    break;
+                case '>':
+                    final boolean commentEndCandidate = ("" + previousPreviousChar + previousChar).equals("--");
+                    if (token == Token.ATTRIBUTE) {
+                        endToken(pos);
+                        //add a one-char-TAG_END token
+                        startToken(Token.TAG_END, pos);
+                        endToken(pos + 1);
+                    } else if (tokenOneOf(Token.TAG, Token.TAG_END) || (token == Token.COMMENT) && commentEndCandidate) {
+                        endToken(pos + 1);
+                    }
+                    break;
+                case ' ':
+                    if (tokenOneOf(Token.TAG)) {
+                        endToken(pos);
+                        startToken(Token.ATTRIBUTE, pos + 1);
+                    }
+                    break;
+                case '\"':
+                    if (tokenOneOf(Token.ATTRIBUTE)) {
+                        endToken(pos + 1);
+                        startToken(Token.ATTRIBUTE_VALUE, pos + 1);
+                    } else if (tokenOneOf(Token.ATTRIBUTE_VALUE)) {
+                        endToken(pos);
+                        startToken(Token.ATTRIBUTE, pos);
+                    }
+                    break;
+                default:
+                    break;
+            }
+            previousPreviousChar = previousChar;
+            previousChar = c;
+        }
+
+        private boolean tokenOneOf(final Token ... tokens) {
+            for (Token t : tokens) {
+                if (this.token == t) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        private void startToken(final Token token, final int tokenStart) {
+            this.token = token;
+            this.tokenStart = tokenStart;
+        }
+
+        private void endToken(final int tokenEnd) {
+            if (token != null && tokenStart >= 0 && tokenStart <= tokenEnd) {
+                foundTokens.add(new TokenData(tokenStart, tokenEnd, token));
+                token = null;
+                tokenStart = -1;
+            }
+        }
+    }
+
 }
diff --git a/main/src/main/java/cgeo/geocaching/wherigo/WherigoGame.java b/main/src/main/java/cgeo/geocaching/wherigo/WherigoGame.java
index ac685c118fd..ffa0114a409 100644
--- a/main/src/main/java/cgeo/geocaching/wherigo/WherigoGame.java
+++ b/main/src/main/java/cgeo/geocaching/wherigo/WherigoGame.java
@@ -414,7 +414,7 @@ public CharSequence toDisplayText(final String text) {
         if (!TextUtils.containsHtml(text)) {
             return text;
         }
-        return HtmlUtils.renderHtml(text, htmlImageGetter::getDrawable, false).first;
+        return HtmlUtils.renderHtml(text, htmlImageGetter::getDrawable).first;
     }
 
     public boolean isDebugModeForCartridge() {
diff --git a/main/src/main/res/values/strings.xml b/main/src/main/res/values/strings.xml
index 5a78202758b..072d1858d12 100644
--- a/main/src/main/res/values/strings.xml
+++ b/main/src/main/res/values/strings.xml
@@ -1766,7 +1766,9 @@
 
     <!-- html styles -->
     <string name="html_style_default">Default</string>
+    <string name="html_style_default_no_contrast_fix">Default w/o contrast fix</string>
     <string name="html_style_monochrome">Monochrome</string>
+    <string name="html_style_source_formatted">HTML Source Formatted</string>
     <string name="html_style_source">HTML Source</string>
 
     <!-- coordinate calculator hints -->