SpannedToHtmlConverter.java

/*
 * Copyright (C) 2020 The Android Open Source Project
 *
 * 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 androidx.media3.ui;

import android.graphics.Typeface;
import android.text.Html;
import android.text.Spanned;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StrikethroughSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import android.text.style.UnderlineSpan;
import android.util.SparseArray;
import androidx.annotation.Nullable;
import androidx.media3.common.text.HorizontalTextInVerticalContextSpan;
import androidx.media3.common.text.RubySpan;
import androidx.media3.common.text.TextAnnotation;
import androidx.media3.common.text.TextEmphasisSpan;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Util;
import com.google.common.collect.ImmutableMap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

/**
 * Utility class to convert from <a
 * href="https://developer.android.com/guide/topics/text/spans">span-styled text</a> to HTML.
 *
 * <p>Supports all of the spans used by subtitle decoders.
 */
/* package */ final class SpannedToHtmlConverter {

  // Matches /n and /r/n in ampersand-encoding (returned from Html.escapeHtml).
  private static final Pattern NEWLINE_PATTERN = Pattern.compile("(&#13;)?&#10;");

  private SpannedToHtmlConverter() {}

  /**
   * Convert {@code text} into HTML, adding tags and styling to match any styling spans present.
   *
   * <p>All textual content is HTML-escaped during the conversion.
   *
   * <p>NOTE: The current implementation does not handle overlapping spans correctly, it will
   * generate overlapping HTML tags that are invalid. In most cases this won't be a problem because:
   *
   * <ul>
   *   <li>Most subtitle formats use a tagged structure to carry formatting information (e.g. WebVTT
   *       and TTML), so the {@link Spanned} objects created by these decoders likely won't have
   *       overlapping spans.
   *   <li>WebView/Chromium (the intended destination of this HTML) gracefully handles overlapping
   *       tags and usually renders the same result as spanned text in a TextView.
   * </ul>
   *
   * @param text The (possibly span-styled) text to convert to HTML.
   * @param displayDensity The screen density of the device. WebView treats 1 CSS px as one Android
   *     dp, so to convert size values from Android px to CSS px we need to know the screen density.
   */
  public static HtmlAndCss convert(@Nullable CharSequence text, float displayDensity) {
    if (text == null) {
      return new HtmlAndCss("", /* cssRuleSets= */ ImmutableMap.of());
    }
    if (!(text instanceof Spanned)) {
      return new HtmlAndCss(escapeHtml(text), /* cssRuleSets= */ ImmutableMap.of());
    }
    Spanned spanned = (Spanned) text;

    // Use CSS inheritance to ensure BackgroundColorSpans affect all inner elements
    Set<Integer> backgroundColors = new HashSet<>();
    for (BackgroundColorSpan backgroundColorSpan :
        spanned.getSpans(0, spanned.length(), BackgroundColorSpan.class)) {
      backgroundColors.add(backgroundColorSpan.getBackgroundColor());
    }
    HashMap<String, String> cssRuleSets = new HashMap<>();
    for (int backgroundColor : backgroundColors) {
      cssRuleSets.put(
          HtmlUtils.cssAllClassDescendantsSelector("bg_" + backgroundColor),
          Util.formatInvariant("background-color:%s;", HtmlUtils.toCssRgba(backgroundColor)));
    }

    SparseArray<Transition> spanTransitions = findSpanTransitions(spanned, displayDensity);
    StringBuilder html = new StringBuilder(spanned.length());
    int previousTransition = 0;
    for (int i = 0; i < spanTransitions.size(); i++) {
      int index = spanTransitions.keyAt(i);
      html.append(escapeHtml(spanned.subSequence(previousTransition, index)));

      Transition transition = spanTransitions.get(index);
      Collections.sort(transition.spansRemoved, SpanInfo.FOR_CLOSING_TAGS);
      for (SpanInfo spanInfo : transition.spansRemoved) {
        html.append(spanInfo.closingTag);
      }
      Collections.sort(transition.spansAdded, SpanInfo.FOR_OPENING_TAGS);
      for (SpanInfo spanInfo : transition.spansAdded) {
        html.append(spanInfo.openingTag);
      }
      previousTransition = index;
    }

    html.append(escapeHtml(spanned.subSequence(previousTransition, spanned.length())));

    return new HtmlAndCss(html.toString(), cssRuleSets);
  }

  private static SparseArray<Transition> findSpanTransitions(
      Spanned spanned, float displayDensity) {
    SparseArray<Transition> spanTransitions = new SparseArray<>();

    for (Object span : spanned.getSpans(0, spanned.length(), Object.class)) {
      @Nullable String openingTag = getOpeningTag(span, displayDensity);
      @Nullable String closingTag = getClosingTag(span);
      int spanStart = spanned.getSpanStart(span);
      int spanEnd = spanned.getSpanEnd(span);
      if (openingTag != null) {
        Assertions.checkNotNull(closingTag);
        SpanInfo spanInfo = new SpanInfo(spanStart, spanEnd, openingTag, closingTag);
        getOrCreate(spanTransitions, spanStart).spansAdded.add(spanInfo);
        getOrCreate(spanTransitions, spanEnd).spansRemoved.add(spanInfo);
      }
    }

    return spanTransitions;
  }

  @Nullable
  private static String getOpeningTag(Object span, float displayDensity) {
    if (span instanceof StrikethroughSpan) {
      return "<span style='text-decoration:line-through;'>";
    } else if (span instanceof ForegroundColorSpan) {
      ForegroundColorSpan colorSpan = (ForegroundColorSpan) span;
      return Util.formatInvariant(
          "<span style='color:%s;'>", HtmlUtils.toCssRgba(colorSpan.getForegroundColor()));
    } else if (span instanceof BackgroundColorSpan) {
      BackgroundColorSpan colorSpan = (BackgroundColorSpan) span;
      return Util.formatInvariant("<span class='bg_%s'>", colorSpan.getBackgroundColor());
    } else if (span instanceof HorizontalTextInVerticalContextSpan) {
      return "<span style='text-combine-upright:all;'>";
    } else if (span instanceof AbsoluteSizeSpan) {
      AbsoluteSizeSpan absoluteSizeSpan = (AbsoluteSizeSpan) span;
      float sizeCssPx =
          absoluteSizeSpan.getDip()
              ? absoluteSizeSpan.getSize()
              : absoluteSizeSpan.getSize() / displayDensity;
      return Util.formatInvariant("<span style='font-size:%.2fpx;'>", sizeCssPx);
    } else if (span instanceof RelativeSizeSpan) {
      return Util.formatInvariant(
          "<span style='font-size:%.2f%%;'>", ((RelativeSizeSpan) span).getSizeChange() * 100);
    } else if (span instanceof TypefaceSpan) {
      @Nullable String fontFamily = ((TypefaceSpan) span).getFamily();
      return fontFamily != null
          ? Util.formatInvariant("<span style='font-family:\"%s\";'>", fontFamily)
          : null;
    } else if (span instanceof StyleSpan) {
      switch (((StyleSpan) span).getStyle()) {
        case Typeface.BOLD:
          return "<b>";
        case Typeface.ITALIC:
          return "<i>";
        case Typeface.BOLD_ITALIC:
          return "<b><i>";
        default:
          return null;
      }
    } else if (span instanceof RubySpan) {
      RubySpan rubySpan = (RubySpan) span;
      switch (rubySpan.position) {
        case TextAnnotation.POSITION_BEFORE:
          return "<ruby style='ruby-position:over;'>";
        case TextAnnotation.POSITION_AFTER:
          return "<ruby style='ruby-position:under;'>";
        case TextAnnotation.POSITION_UNKNOWN:
          return "<ruby style='ruby-position:unset;'>";
        default:
          return null;
      }
    } else if (span instanceof UnderlineSpan) {
      return "<u>";
    } else if (span instanceof TextEmphasisSpan) {
      TextEmphasisSpan textEmphasisSpan = (TextEmphasisSpan) span;
      String style = getTextEmphasisStyle(textEmphasisSpan.markShape, textEmphasisSpan.markFill);
      String position = getTextEmphasisPosition(textEmphasisSpan.position);
      return Util.formatInvariant(
          "<span style='-webkit-text-emphasis-style:%1$s;text-emphasis-style:%1$s;"
              + "-webkit-text-emphasis-position:%2$s;text-emphasis-position:%2$s;"
              + "display:inline-block;'>",
          style, position);
    } else {
      return null;
    }
  }

  @Nullable
  private static String getClosingTag(Object span) {
    if (span instanceof StrikethroughSpan
        || span instanceof ForegroundColorSpan
        || span instanceof BackgroundColorSpan
        || span instanceof HorizontalTextInVerticalContextSpan
        || span instanceof AbsoluteSizeSpan
        || span instanceof RelativeSizeSpan
        || span instanceof TextEmphasisSpan) {
      return "</span>";
    } else if (span instanceof TypefaceSpan) {
      @Nullable String fontFamily = ((TypefaceSpan) span).getFamily();
      return fontFamily != null ? "</span>" : null;
    } else if (span instanceof StyleSpan) {
      switch (((StyleSpan) span).getStyle()) {
        case Typeface.BOLD:
          return "</b>";
        case Typeface.ITALIC:
          return "</i>";
        case Typeface.BOLD_ITALIC:
          return "</i></b>";
      }
    } else if (span instanceof RubySpan) {
      RubySpan rubySpan = (RubySpan) span;
      return "<rt>" + escapeHtml(rubySpan.rubyText) + "</rt></ruby>";
    } else if (span instanceof UnderlineSpan) {
      return "</u>";
    }
    return null;
  }

  private static String getTextEmphasisStyle(
      @TextEmphasisSpan.MarkShape int shape, @TextEmphasisSpan.MarkFill int fill) {
    StringBuilder builder = new StringBuilder();
    switch (fill) {
      case TextEmphasisSpan.MARK_FILL_FILLED:
        builder.append("filled ");
        break;
      case TextEmphasisSpan.MARK_FILL_OPEN:
        builder.append("open ");
        break;
      case TextEmphasisSpan.MARK_FILL_UNKNOWN:
      default:
        break;
    }

    switch (shape) {
      case TextEmphasisSpan.MARK_SHAPE_CIRCLE:
        builder.append("circle");
        break;
      case TextEmphasisSpan.MARK_SHAPE_DOT:
        builder.append("dot");
        break;
      case TextEmphasisSpan.MARK_SHAPE_SESAME:
        builder.append("sesame");
        break;
      case TextEmphasisSpan.MARK_SHAPE_NONE:
        builder.append("none");
        break;
      default:
        builder.append("unset");
        break;
    }
    return builder.toString();
  }

  private static String getTextEmphasisPosition(@TextAnnotation.Position int position) {
    switch (position) {
      case TextAnnotation.POSITION_AFTER:
        return "under left";
      case TextAnnotation.POSITION_UNKNOWN:
      case TextAnnotation.POSITION_BEFORE:
      default:
        return "over right";
    }
  }

  private static Transition getOrCreate(SparseArray<Transition> transitions, int key) {
    @Nullable Transition transition = transitions.get(key);
    if (transition == null) {
      transition = new Transition();
      transitions.put(key, transition);
    }
    return transition;
  }

  private static String escapeHtml(CharSequence text) {
    String escaped = Html.escapeHtml(text);
    return NEWLINE_PATTERN.matcher(escaped).replaceAll("<br>");
  }

  /** Container class for an HTML string and associated CSS rulesets. */
  public static class HtmlAndCss {

    /** A raw HTML string. */
    public final String html;

    /**
     * CSS rulesets used to style {@link #html}.
     *
     * <p>Each key is a CSS selector, and each value is a CSS declaration (i.e. a semi-colon
     * separated list of colon-separated key-value pairs, e.g "prop1:val1;prop2:val2;").
     */
    public final Map<String, String> cssRuleSets;

    private HtmlAndCss(String html, Map<String, String> cssRuleSets) {
      this.html = html;
      this.cssRuleSets = cssRuleSets;
    }
  }

  private static final class SpanInfo {
    /**
     * Sort by end index (descending), then by opening tag and then closing tag (both ascending, for
     * determinism).
     */
    private static final Comparator<SpanInfo> FOR_OPENING_TAGS =
        (info1, info2) -> {
          int result = Integer.compare(info2.end, info1.end);
          if (result != 0) {
            return result;
          }
          result = info1.openingTag.compareTo(info2.openingTag);
          if (result != 0) {
            return result;
          }
          return info1.closingTag.compareTo(info2.closingTag);
        };

    /**
     * Sort by start index (descending), then by opening tag and then closing tag (both descending,
     * for determinism).
     */
    private static final Comparator<SpanInfo> FOR_CLOSING_TAGS =
        (info1, info2) -> {
          int result = Integer.compare(info2.start, info1.start);
          if (result != 0) {
            return result;
          }
          result = info2.openingTag.compareTo(info1.openingTag);
          if (result != 0) {
            return result;
          }
          return info2.closingTag.compareTo(info1.closingTag);
        };

    public final int start;
    public final int end;
    public final String openingTag;
    public final String closingTag;

    private SpanInfo(int start, int end, String openingTag, String closingTag) {
      this.start = start;
      this.end = end;
      this.openingTag = openingTag;
      this.closingTag = closingTag;
    }
  }

  private static final class Transition {
    private final List<SpanInfo> spansAdded;
    private final List<SpanInfo> spansRemoved;

    public Transition() {
      this.spansAdded = new ArrayList<>();
      this.spansRemoved = new ArrayList<>();
    }
  }
}