最近在制作 Typecho default-ultra 主题,在这个主题中我加入了对文章字数统计的功能。此前我也参考了其他优秀的开发者的文章字数统计方式,我自己也优化了好几次。

文章字数统计,看起来是一个很简单的小功能,为什么值得被优化好几次?其实原因很简单,现存的方案统计出来的结果,与预估结果误差太大。

先看看现有的文章字数统计方案。

方案 1:源自百度搜索结果。

function count($cid) {
    // 方法传入文章cid,然后根据cid从数据库中获取文章的内容
    $db = Typecho_Db::get();
    $row = $db->fetchRow ($db->select ('table.contents.text')->from ('table.contents')->where ('table.contents.cid = ?', $cid)->order ('table.contents.cid', Typecho_Db::SORT_ASC)->limit (1));
    $text = $row['text'];

    // 自动检测原文本的编码并转换为UTF-8编码
    $text = mb_convert_encoding($text, 'UTF-8', 'auto');
    // 过滤空白字符
    $text = str_replace('/( |\s|\r\n|\n|\r)+/u', '', $text);
    // 使用正则替换删除所有非中文字符
    $text = preg_replace('/[^\x{4e00}-\x{9fa5}]/u', '', $text);
    // 计算中文字符长度
    $count = mb_strlen($text, 'UTF-8');

    // 返回统计结果
    return $count;
}

这个方案的问题在于:它只统计中文字符的数量,文章中的英文不统计?数字也不统计?按理来说,中文字符、英文、数字都要统计到字数中去的。

方案 2:源自百度搜索结果。

<?php
// 方法直接传入文章的内容,输出计算结果
echo mb_strlen($this->content);
?>

这个方案的问题在于:mb_strlen 函数能够统计中文、日文、英文、数字等等,UTF-8 编码的中文字符计为 1 个长度,但是一个英文字母也会被计为 1 个长度。理论上来说,一个完整的单词才能计为 1 个长度。本质上,mb_strlen 函数计算的是 Unicode 字符数。

基本上所有的文章字数统计方案,都没办法很好的计算出预估结果。

对于一般情况下的字数统计,我的预估效果是这样的:

  • 1 个中文算 1 个字,如“你好”算 2 个字。
  • 1 个中文符号算 1 个字,如“你好。” 算 3 个字。中文标点符号占用的是全角字符的位置,每一个标点符号都占有等宽的空间,相当于一个汉字,因此中文符号也可以做为独立的元素被计入字数。
  • 1 个英文单词算 1 个字,如“hello world”算 2 个字,“user-name”算 1 个字,“don't”算 1 个字。对于英文来说,字数的统计焦点在于单词的数量。
  • 英文符号不计算在内。英文符号多为半角字符,通常被用来分隔单词或短语。英文符号在字数统计中不被计算是因为它们的主要功能是语法上的,而非构成独立语义单位。
  • 1 串完整的数字算 1 个字,如“123”算 1 个字,“138 888 8899”算 3 个字。
  • 1 串完整的数字 + 英文算 1 个字,如“user123”算 1 个字,“https://www.duozai.cn/page/7.html”算 1 个字。
  • 空字符串不计算在内。
  • 其他 Unicode 如日文、韩文,正常计算字符。

对于 Typecho Markdown 格式的文章字数统计,我的预估效果是这样的:

  • Markdown 的所有标记都不能算入字数,如标题井号、分隔符横杠、列表星号横杠等。
  • Markdown 图片链接不能算入字数,图片描述算入字数。
  • Markdown 链接地址不能算入字数,链接描述算入字数。
  • Markdown 代码块中的内容算入字数,代码块标记、代码块语言标记不能算入字数。
  • Typecho Markdown 格式的文章文本开头有一个固定的 markdown 注释,这也不能算入字数。

在多次优化和调整之下,整出了个文章字数统计的方法:

/**
 * 计算文章字数
 */
function postWordCount($archive) {
    $db = Typecho_Db::get ();
    $cid = $archive->cid;
    // 获取文章内容
    $rs = $db->fetchRow($db->select ('table.contents.text')->from ('table.contents')->where ('table.contents.cid = ?', $cid)->order ('table.contents.cid', Typecho_Db::SORT_ASC)->limit (1));
    $content = $rs['text'];
    
    // 匹配 Markdown 标记的正则规则
    $rules = [
        '/<!--markdown-->/' => '',
        '/^#{1,6}\s+/m' => '',
        '/(\*{1,2}|_{1,2})/' => '',
        '/\[([^\]]+)\]\([^)]+\)/' => '$1',
        '/!\[([^\]]*)\]\([^)]+\)/' => '$1',
        '/‍```.*?\R/' => '',
        '/\R‍```/' => '',
        '/`/' => '',
        '/^[ \t]*[-*+]\s+/m' => '',
        '/^[ \t]*\d+\.\s+/m' => '',
        '/^>\s*/m' => '',
        '/^[-*_]{3,}\s*$/m' => '',
        '/\|/' => '',
        '/^[-: ]+$/' => '',
    ];
    
    foreach ($rules as $pattern => $replacement) {
        // 移除 Markdown 标记
        $content = preg_replace($pattern, $replacement, $content);
    }

    if (empty($content)) {
        return 0;
    }

    // 处理空格(合并连续空格为1个,保留分隔作用)
    $content = preg_replace('/\s+/', ' ', $content);
    $content = trim($content);
    if ($content === '') {
        return 0;
    }

    // 字数计数器
    $count = 0;
    
    // 按空格拆分成独立块
    $blocks = preg_split('/\s+/', $content);

    foreach ($blocks as $block) {
        if (empty($block)) {
            // 空格不计数
            continue;
        }

        // 核心正则:按规则优先级拆分内容(先拆1-2类,再拆3类,最后剩4类)
        // 匹配单个中文字符、单个中文符号、连续的英文/数字/英文符号序列、其他 Unicode
        preg_match_all('/([\x{4e00}-\x{9fa5}]|[\x{3000}-\x{303f}\x{ff00}-\x{ffef}]|[a-zA-Z0-9\!\@\#\$\%\^\&\*\(\)\-\_\+\=\[\]\{\}\|\\\;\:\'\"\,\.\<\>\/\?\`]+|.)/ux', $block, $matches);

        // 按规则计数
        foreach ($matches[0] as $part) {
            // 所有规则均为“单个/连续序列算1”,直接累加
            $count++;
        }
    }

    // 向上取整统计大约字数
    return ceil($count / 10) * 10;
}

这个方案,基本满足了我的预估效果。