Typecho_Common::fixHtml 函数处理自闭合标签时的问题

起因

在折腾 icarus 移植主题的时候,想到给文章摘要 (excerpt) 增加一个“保留摘要样式”的选项(Typecho 默认的摘要摘取策略是:去除所有的 HTML 标签,只保留文本内容)。原以为在 strip_tags 函数调用前做一个判断就可以了,但这样做以后,发现在摘要的末尾莫名地出现了诸如 </br> </img> 等奇怪的标记。为此稍微折腾了一番,发现了 Typecho_Common::fixHtml 函数的一个处理自闭合标签的 Bug。手动 Patch 了一下,本打算来一个 Pull Request,最后发现开发版已经修复了这个 Bug 了(但正式版 17.10.30 尚未更新,因此此 Bug 截至目前(2019/2/6)仍是尚未修复的状态,在开发主题、插件时需要注意一下)。这里整理了一下当时的一些笔记。

溯源

相关代码(一)

Widget_Abstract_Contents::excerpt()

/**
 * 输出文章摘要
 *
 * @access public
 * @param integer $length 摘要截取长度
 * @param string $trim 摘要后缀
 */
public function excerpt($length = 100, $trim = '...')
{
    echo Typecho_Common::subStr(
        strip_tags($this->excerpt), 0, $length, $trim
    ); // 去除摘要中的 HTML 标签,然后按照 $length 取得指定长度的摘要
}

Widget_Abstract_Contents::___excerpt()

/**
 * 获取文章内容摘要
 *
 * @access protected
 * @return string
 */
protected function ___excerpt()
{
    // 针对加密文章的特殊流程
    if ($this->hidden) { 
        return $this->text; 
        // 加密文章的 text 会被修改为密码输入框 直接返回 text 即可
        // 参考 Widget_Abstract_Contents::filter()
    }
    
    // 取得文章正文
    // 支持插件在摘要生成前将处理 正文 -> HTML 的流程
    $content = $this->pluginHandle(__CLASS__)
                    ->trigger($plugged)
                    ->excerpt($this->text, $this);
    
    if (!$plugged) { 
        // 正文处理的默认实现
        $content = $this->isMarkdown 
            ? $this->markdown($content)
            : $this->autoP($content);
    }
    
    // 根据摘要标记进行截断以生成摘要
    $contents = explode('<!--more-->', $content); 
    
    // 相当于 $excerpt = $contents[0];
    list($excerpt) = $contents; 
    
    // 支持插件在摘要生成后再进行修改
    // 调用 fixHtml 补齐因 explode 截断而丢失的结束标签(如 </p>)
    return Typecho_Common::fixHtml(
        $this->pluginHandle(__CLASS__)
             ->excerptEx($excerpt, $this)
    );
}

推测

先来排除法。

  • 可以推测取得文章正文 (content) 的代码是正常工作的(否则正文也会出现异常的 </br> 等标签)。
  • 另外从功能上暂时排除掉会让内容减少而不是增多的 strip_tags 以及 Typecho_Common::subStr 函数。

因此头号嫌疑是剩下来的一个 Typecho_Common::fixHtml 函数。下面进行一个简单的测试:

<?php
$html = <<<HTML
<p>段落<br>第二行<br><img src="http://github.com/"></p>
HTML;
echo Typecho_Common::fixHtml($html);

得到的结果是:

<p>段落<br>第二行<br><img src="http://github.com/"></p></img></br></br>

可以发现的确是 Typecho_Common::fixHtml 函数给摘要追加了 </br> 等错误的结束标签。

相关代码(二)

/**
 * 自闭合html修复函数
 * 使用方法:
 * <code>
 * $input = '这是一段被截断的html文本<a href="#"';
 * echo Typecho_Common::fixHtml($input);
 * //output: 这是一段被截断的html文本
 * </code>
 *
 * @access public
 * @param string $string 需要修复处理的字符串
 * @return string
 */
public static function fixHtml($string)
{
    // Step 1: 去除不完整的起始标签(见“使用方法”)
    
    $startPos = strrpos($string, "<");

    if (false == $startPos) {
        return $string;
    }

    $trimString = substr($string, $startPos);

    if (false === strpos($trimString, ">")) {
        $string = substr($string, 0, $startPos);
    }
    
    // Step 2: 关闭非自闭合标签
    
    // 起始标签列表 (<tagName>)
    preg_match_all("/<([_0-9a-zA-Z-\:]+)\s*([^>]*)>/is", $string, $startTags);
    // 结束标签列表 (</tagName>)
    preg_match_all("/<\/([_0-9a-zA-Z-\:]+)>/is", $string, $closeTags);

    if (!empty($startTags[1]) && is_array($startTags[1])) {
        // 反转起始标签列表顺序(原起始与结束标签列表顺序相反)
        krsort($startTags[1]);
        
        $closeTagsIsArray = is_array($closeTags[1]);
        foreach ($startTags[1] as $key => $tag) {
            $attrLength = strlen($startTags[2][$key]);
            if ($attrLength > 0 && "/" == trim($startTags[2][$key][$attrLength - 1])) {
                // 若起始标签为自闭合标签则跳过(形如<br />)
                continue;
            }
            
            // 查找起始标签是否有对应的结束标签
            if (!empty($closeTags[1]) && $closeTagsIsArray) {
                if (false !== ($index = array_search($tag, $closeTags[1]))) {
                    unset($closeTags[1][$index]);
                    continue;
                }
            }
            
            // 没有对应的结束标签则进行补充
            $string .= "</{$tag}>";
        }
    }

    return preg_replace("/\<br\s*\/\>\s*\<\/p\>/is", '</p>', $string);
}

修复

fixHtml 函数的原理是补回缺失的结束标签,但在第44行判断自闭合标签时忽略了 HTML 中自闭合标签不必要带有一个正斜杠,比如换行标签: <br> <br/> <br /> 三种写法都是正确的,因此单凭 /> 进行自闭合标签的判断是有缺陷的。

查询文档发现,HTML 标记中,自闭合标签(称作 空元素 (Void Elements) )是有限个的,它们分别是 <area>, <base>, <br>, <col>, <embed>, <hr>, <img>, <input>, <link>, <meta>, <param>, <source>, <track>, <wbr>

因此,要让 fixHtml 正常工作,有以下两个思路:

增加针对自闭合标签的白名单

这个是目前 Typecho 开发版使用的方案。参考 var/Typecho/Common.php - commit c056f6c - typecho/typecho - GitHub

Diff:

  $attrLength = strlen($startTags[2][$key]);
  if ($attrLength > 0 && "/" == trim($startTags[2][$key][$attrLength - 1])) {
      continue;
  }

+ // 白名单
+ if (preg_match("/^(area|base|br|col|embed|hr|img|input|keygen".
+                "|link|meta|param|source|track|wbr)$/i", $tag)) {
+     continue;
+ }

  if (!empty($closeTags[1]) && $closeTagsIsArray) {
      if (false !== ($index = array_search($tag, $closeTags[1]))) {
          unset($closeTags[1][$index]);
          continue;
      }
  }

改变 fixHtml 函数的输入

既然 fixHtml 函数不支持 <br> 这种形式的自闭合标签,但支持 <br /> 这种形式的自闭合标签,可以想到的一个思路是在调用 fixHtml 函数前对输入进行处理,将 <br> 等替换为 <br /> 这种形式。可以利用 Typecho 提供的 excerptEx 插件接口实现。

function voidElementsPatch($excerpt, $widget) 
{
    return preg_replace(
        '/\<(area|base|br|col|embed|hr|img|input|keygen'.
            '|link|meta|param|source|track|wbr)([^\>]*?)\s*\/?>/i', 
        '<\1\2 />', 
        $excerpt);
}

Typecho_Plugin::factory('Widget_Abstract_Contents')
    ->excerptEx = 'voidElementsPatch';

参考资料

  1. Void Elements - Syntax - HTML 5.2 W3C Recommendation
  2. var/Typecho/Common.php - commit c056f6c - typecho/typecho - GitHub
  3. Issue #636 - typecho/typecho - GitHub
  4. 1.1新版添加摘要符号后会出现好多换行符 - Typecho 论坛
本文采用 署名-相同方式共享 4.0 国际许可协议 进行许可。
本文作者:KeNorizon
本文链接:https://blog.kenorizon.cn/code/typecho-fix-html-func-bug.html

评论

1 条

云武

有插件版吗?

回复 ·

添加新评论