docs: @VisibleForTesting
    static String stripHtmlTags(String value, TagWhitelist safeTags, boolean rawSpacesAllowed) {
      EscapingConventions.CrossLanguageStringXform normalizer =
          rawSpacesAllowed
              ? EscapingConventions.NormalizeHtml.INSTANCE
              : EscapingConventions.NormalizeHtmlNospace.INSTANCE;
  
      Matcher matcher = EscapingConventions.HTML_TAG_CONTENT.matcher(value);
      if (!matcher.find()) {
        // Normalize so that the output can be embedded in an HTML attribute.
        return normalizer.escape(value);
      }
  
      StringBuilder out = new StringBuilder(value.length() - matcher.end() + matcher.start());
      Appendable normalizedOut = normalizer.escape(out);
      // We do some very simple tag balancing by dropping any close tags for unopened tags and at the
      // end emitting close tags for any still open tags.
      // This is sufficient (in HTML) to prevent embedded content with safe tags from breaking layout
      // when, for example, stripHtmlTags("</table>") is embedded in a page that uses tables for
      // formatting.
      List<String> openTags = null;
      int openListTagCount = 0;
      try {
        int pos = 0; // Such that value[:pos] has been sanitized onto out.
        do {
          int start = matcher.start();
  
          if (pos < start) {
            normalizedOut.append(value, pos, start);
  
            // More aggressively normalize ampersands at the end of a chunk so that
            //   "&<b>amp;</b>" -> "&amp;amp;" instead of "&amp;".
            if (value.charAt(start - 1) == '&') {
              out.append("amp;");
            }
          }
  
          if (safeTags != null) {
            String tagName = matcher.group(1);
            if (tagName != null) {
              // Use locale so that <I> works when the default locale is Turkish
              tagName = Ascii.toLowerCase(tagName);
              if (safeTags.isSafeTag(tagName)) {
                boolean isClose = value.charAt(start + 1) == '/';
                if (isClose) {
                  if (openTags != null) {
                    int lastIdx = openTags.lastIndexOf(tagName);
                    if (lastIdx >= 0) {
                      // Close contained tags as well.
                      // If we didn't, then we would convert "<ul><li></ul>" to "<ul><li></ul></li>"
                      // which could lead to broken layout for embedding HTML that uses lists for
                      // formatting.
                      // This leads to observably different behavior for adoption-agency dependent
                      // tag combinations like "<b><i>Foo</b> Bar</b>" but fails safe.
                      // http://www.whatwg.org/specs/web-apps/current-work/multipage/the-end.html#misnested-tags:-b-i-/b-/i
                      List<String> tagsToClose = openTags.subList(lastIdx, openTags.size());
                      for (String tagToClose : tagsToClose) {
                        if (isListTag(tagToClose)) {
                          openListTagCount--;
                        }
                      }
                      closeTags(tagsToClose, out);
                    }
                  }
                } else {
                  // Only allow whitelisted <li> through if it is nested in a parent <ol> or <ul>.
                  if (openListTagCount > 0 || !"li".equals(tagName)) {
                    if (isListTag(tagName)) {
                      openListTagCount++;
                    }
  
                    // Emit beginning of the opening tag and tag name on the un-normalized channel.
                    out.append('<').append(tagName);
  
                    // Most attributes are dropped, but the dir attribute is preserved if it exists.
                    // The attribute matching could be made more generic if more attributes need to be
                    // whitelisted in the future. There are also probably other utilities in common to
                    // do such parsing of HTML, but this seemed simple enough and keeps with the
                    // current spirit of this function of doing custom parsing.
                    Matcher attributeMatcher = HTML_ATTRIBUTE_PATTERN.matcher(matcher.group());
                    while (attributeMatcher.find()) {
                      String attributeName = attributeMatcher.group(1);
                      if (!Strings.isNullOrEmpty(attributeName)
                          && Ascii.equalsIgnoreCase(attributeName, "dir")) {
                        String dir = attributeMatcher.group(2);
                        if (!Strings.isNullOrEmpty(dir)) {
                          // Strip quotes if the attribute value was quoted.
                          if (dir.charAt(0) == '\'' || dir.charAt(0) == '"') {
                            dir = dir.substring(1, dir.length() - 1);
                          }
                          dir = Ascii.toLowerCase(dir);
                          if ("ltr".equals(dir) || "rtl".equals(dir) || "auto".equals(dir)) {
                            out.append(" dir=\"").append(dir).append("\"");
                          }
                        }
                        break;
                      }
                    }
  
                    // Emit the end of the opening tag
                    out.append('>');
  
                    // Keep track of tags that need closing.
                    if (!HTML5_VOID_ELEMENTS.contains(tagName)) {
                      if (openTags == null) {
                        openTags = Lists.newArrayList();
                      }
                      openTags.add(tagName);
                    }
                  }
                }
              }
            }
          }
          pos = matcher.end();
        } while (matcher.find());
        normalizedOut.append(value, pos, value.length());
        // Emit close tags, so that safeTags("<table>") can't break the layout of embedding HTML that
        // uses tables for layout.
        if (openTags != null) {
          closeTags(openTags, out);
        }
      } catch (IOException ex) {
        // Writing to a StringBuilder should not throw.
        throw new AssertionError(ex);
      }
      return out.toString();
    }