htmlUtils.js 7.31 KB
/**
 * HTML 工具函数库
 *
 * @module utils/htmlUtils
 * @description 提供 HTML 处理相关的工具函数,包括实体编码/解码、标签清理等
 */

/**
 * HTML 实体解码(完整版)
 *
 * @description
 * - H5 环境:使用 DOM API 自动解码(最可靠)
 * - 小程序环境:使用完整的手动映射表(200+ 实体)
 *
 * @param html - 包含 HTML 实体的字符串
 * @returns 解码后的字符串
 *
 * @example
 * // 基本使用
 * decodeHtmlEntities('Hello & "World" © 2025')
 * // => 'Hello "World" © 2025'
 *
 * @example
 * // 数学符号
 * decodeHtmlEntities('5 × 3 = 15 ÷ 3 = 5')
 * // => '5 × 3 = 15 ÷ 3 = 5'
 *
 * @example
 * // 数字实体
 * decodeHtmlEntities('Smile: 😄 or 😀')
 * // => 'Smile: 😀 or 😀'
 */
export const decodeHtmlEntities = (html) => {
  if (!html) return ''

  // 方法1:H5 环境使用 DOM API(最可靠,支持所有实体)
  if (process.env.TARO_ENV === 'h5' && typeof document !== 'undefined') {
    try {
      const textArea = document.createElement('textarea')
      textArea.innerHTML = html
      const decoded = textArea.value
      // 验证解码是否成功
      if (decoded !== html) {
        return decoded
      }
    } catch (e) {
      console.warn('[htmlUtils] DOM API 解码失败,降级到手动映射')
    }
  }

  // 方法2:小程序环境或降级方案 - 完整手动映射
  const entityMap = {
    // 基本符号
    '&nbsp;': '\u00A0', '&amp;': '&', '&lt;': '<', '&gt;': '>',
    '&quot;': '"', '&apos;': "'", '&Oslash;': 'Ø', '&oslash;': 'ø',

    // 货币符号
    '&cent;': '¢', '&pound;': '£', '&yen;': '¥', '&euro;': '€', '&curren;': '¤',

    // 版权/商标
    '&copy;': '©', '&reg;': '®', '&trade;': '™',

    // 数学符号
    '&times;': '×', '&divide;': '÷', '&plusmn;': '±', '&frac12;': '½',
    '&frac14;': '¼', '&frac34;': '¾', '&sup2;': '²', '&sup3;': '³',
    '&radic;': '√', '&sum;': 'Σ', '&prod;': 'Π', '&mu;': 'μ', '&pi;': 'π',
    '&Omega;': 'Ω', '&infin;': '∞', '&asymp;': '≈', '&ne;': '≠',
    '&le;': '≤', '&ge;': '≥', '&deg;': '°', '&prime;': '′', '&Prime;': '″',

    // 箭头
    '&larr;': '←', '&rarr;': '→', '&uarr;': '↑', '&darr;': '↓',
    '&harr;': '↔', '&crarr;': '↵',

    // 引号
    '&ldquo;': '"', '&rdquo;': '"', '&lsquo;': '\u2018', '&rsquo;': '\u2019',
    '&sbquo;': '\u201A', '&bdquo;': '\u201E',

    // 破折号
    '&mdash;': '—', '&ndash;': '–',

    // 省略号与特殊标点
    '&hellip;': '…', '&bull;': '•', '&para;': '¶', '&sect;': '§',
    '&middot;': '·', '&caret;': '\u2006',

    // 括号
    '&laquo;': '«', '&raquo;': '»', '&lsaquo;': '‹', '&rsaquo;': '›',

    // 重音字母(大写)
    '&Agrave;': 'À', '&Aacute;': 'Á', '&Acirc;': 'Â', '&Atilde;': 'Ã',
    '&Auml;': 'Ä', '&Aring;': 'Å', '&AElig;': 'Æ', '&Ccedil;': 'Ç',
    '&Egrave;': 'È', '&Eacute;': 'É', '&Ecirc;': 'Ê', '&Euml;': 'Ë',
    '&Igrave;': 'Ì', '&Iacute;': 'Í', '&Icirc;': 'Î', '&Iuml;': 'Ï',
    '&ETH;': 'Ð', '&Ntilde;': 'Ñ', '&Ograve;': 'Ò', '&Oacute;': 'Ó',
    '&Ocirc;': 'Ô', '&Otilde;': 'Õ', '&Ouml;': 'Ö', '&Oslash;': 'Ø',
    '&Scaron;': 'Š', '&Ugrave;': 'Ù', '&Uacute;': 'Ú', '&Ucirc;': 'Û',
    '&Uuml;': 'Ü', '&Yacute;': 'Ý', '&THORN;': 'Þ',

    // 重音字母(小写)
    '&agrave;': 'à', '&aacute;': 'á', '&acirc;': 'â', '&atilde;': 'ã',
    '&auml;': 'ä', '&aring;': 'å', '&aelig;': 'æ', '&ccedil;': 'ç',
    '&egrave;': 'è', '&eacute;': 'é', '&ecirc;': 'ê', '&euml;': 'ë',
    '&igrave;': 'ì', '&iacute;': 'í', '&icirc;': 'î', '&iuml;': 'ï',
    '&eth;': 'ð', '&ntilde;': 'ñ', '&ograve;': 'ò', '&oacute;': 'ó',
    '&ocirc;': 'ô', '&otilde;': 'õ', '&ouml;': 'ö', '&oslash;': 'ø',
    '&scaron;': 'š', '&ugrave;': 'ù', '&uacute;': 'ú', '&ucirc;': 'û',
    '&uuml;': 'ü', '&yacute;': 'ý', '&yuml;': 'ÿ', '&thorn;': 'þ',

    // 希腊字母
    '&Alpha;': 'Α', '&Beta;': 'Β', '&Gamma;': 'Γ', '&Delta;': 'Δ',
    '&Epsilon;': 'Ε', '&Zeta;': 'Ζ', '&Eta;': 'Η', '&Theta;': 'Θ',
    '&Iota;': 'Ι', '&Kappa;': 'Κ', '&Lambda;': 'Λ', '&Mu;': 'Μ',
    '&Nu;': 'Ν', '&Xi;': 'Ξ', '&Omicron;': 'Ο', '&Pi;': 'Π',
    '&Rho;': 'Ρ', '&Sigma;': 'Σ', '&Tau;': 'Τ', '&Upsilon;': 'Υ',
    '&Phi;': 'Φ', '&Chi;': 'Χ', '&Psi;': 'Ψ', '&Omega;': 'Ω',
    '&alpha;': 'α', '&beta;': 'β', '&gamma;': 'γ', '&delta;': 'δ',
    '&epsilon;': 'ε', '&zeta;': 'ζ', '&eta;': 'η', '&theta;': 'θ',
    '&iota;': 'ι', '&kappa;': 'κ', '&lambda;': 'λ', '&mu;': 'μ',
    '&nu;': 'ν', '&xi;': 'ξ', '&omicron;': 'ο', '&pi;': 'π',
    '&rho;': 'ρ', '&sigmaf;': 'ς', '&sigma;': 'σ', '&tau;': 'τ',
    '&upsilon;': 'υ', '&phi;': 'φ', '&chi;': 'χ', '&psi;': 'ψ',
    '&omega;': 'ω',

    // 空格变体
    '&ensp;': '\u2002', '&emsp;': '\u2003', '&thinsp;': '\u2009',
    '&zwnj;': '\u200C', '&zwj;': '\u200D', '&lrm;': '\u200E', '&rlm;': '\u200F'
  }

  let result = html

  // 先处理数字实体(如 &#123; 和 &#x1F600;)
  result = result.replace(/&#(\d+);/g, (_match, dec) => {
    return String.fromCharCode(dec)
  }).replace(/&#x([0-9a-fA-F]+);/g, (_match, hex) => {
    return String.fromCharCode(parseInt(hex, 16))
  })

  // 再处理命名实体
  for (const [entity, char] of Object.entries(entityMap)) {
    result = result.split(entity).join(char)
  }

  return result
}

/**
 * HTML 实体编码
 *
 * @description 将特殊字符转换为 HTML 实体,用于安全地显示用户输入
 *
 * @param text - 需要编码的文本
 * @returns 编码后的 HTML 实体字符串
 *
 * @example
 * encodeHtmlEntities('<script>alert("XSS")</script>')
 * // => '&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;'
 */
export const encodeHtmlEntities = (text) => {
  if (!text) return ''

  const entityMap = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&apos;',
    ' ': '&nbsp;'
  }

  return text.replace(/[&<>"' ]/g, char => entityMap[char] || char)
}

/**
 * 移除 HTML 标签
 *
 * @description 从字符串中移除所有 HTML 标签,保留纯文本内容
 *
 * @param html - 包含 HTML 标签的字符串
 * @returns 纯文本内容
 *
 * @example
 * stripHtmlTags('<p>Hello <strong>World</strong>!</p>')
 * // => 'Hello World!'
 */
export const stripHtmlTags = (html) => {
  if (!html) return ''
  return html.replace(/<[^>]*>/g, '')
}

/**
 * 截取 HTML 并保留标签完整性
 *
 * @description 截取 HTML 字符串到指定长度,确保不会截断标签
 *
 * @param html - HTML 字符串
 * @param maxLength - 最大长度(字符数)
 * @param suffix - 截断后缀(默认 '...')
 * @returns 截取后的 HTML 字符串
 *
 * @example
 * truncateHtml('<p>Hello World</p>', 5)
 * // => '<p>Hello...</p>'
 */
export const truncateHtml = (html, maxLength, suffix = '...') => {
  if (!html || html.length <= maxLength) return html

  // 先移除标签获取纯文本长度
  const plainText = stripHtmlTags(html)
  if (plainText.length <= maxLength) return html

  // 简单截取(高级实现需要 AST 解析)
  let result = html.substring(0, maxLength)
  const lastOpenTag = result.lastIndexOf('<')
  const lastCloseTag = result.lastIndexOf('>')

  // 如果最后一个是未闭合的标签,移除它
  if (lastOpenTag > lastCloseTag) {
    result = result.substring(0, lastOpenTag)
  }

  return result + suffix
}