37DATA

探索可拓展的多语言字符计数器

一、背景

之所以做这个工具,其实也是来自于项目的需要。我们在进行AI翻译项目的时候,需要评估每一份翻译文件的字符数,根据字符数来对比旧的购买人工翻译服务的费用,以及使用AI翻译的成本。 需求方要求能有一个工具,可以对多语言的文档进行字数统计,字数统计基本与word文档中的计数器的结果基本一致。(意味着在开发时可以以word文档计数器作为参考,进行偏差修正)

二、难点

计算一份文档中的字符的个数,咋一看似乎很简单,各大语言都有不管是占用字节的计数,或者是单独一个字符的计数。以php为例:
$str = "你好";
echo mb_strlen($str, 'utf-8');
会输出字符数量:2
$str = "你好";
echo strlen($str);
会输出在utf-8编码中所占的字节数:6 那么,我们的需求方的需求是“字数”,那是否可以直接用类似 mb_strlen 这样的函数的计数方式呢? 答案是否定的,因为这个仅仅是中文的情况,一旦加入英文就想不通了,英文是需要连续的字母组成的一个单次作为一个单词。再者,还有其他语言,有俄文、波斯文、越南语等等各有各规则的计数方式。

三、探索

先来看一张图:

图中是提取了需求方真实用来翻译的片段,试验了5种计数的方式: 1、正则匹配计数,目前看来这种效果最接近word文档参考的字符计数;
2、go模块bufio,对于英文支持比较好,不支持中文;
3、unicode编码进行计数,效果不理想,英文勉强误差较小,中文效果不好;
4、分词库gojieba,这个库是“结巴分词”的go版本,它的计数不是计算字符,而是将中文语义中的“词”进行拆分,因此跟word文档中的计数相差很大,但是感觉这种可能才是中文计数的正确打开方式,效果是这样:

5、第三方模块wordscount,这个是我对比了多个个人开发者,找到的一个考虑的比较全面的库,它考虑了字符、标点符号等的计数:

使用结果看来,对于中文的计数跟word文档的计数是比较接近的,英文则有较大的误差。 从5个方案看下来, 正则 似乎是最接近答案的一个解决方案,只不过对于越南文等存在特殊字符的计数存在问题。

四、方案选择

最终我们选择了正则的方式。
那我们如何解决复杂的不同语种的字符计数呢?
解决这个问题,主要围绕着“通用计数“+”灵活扩展“进行。

五、通用计数

其实正则的核心实现也并非我原创,而是在查找字符计数主流解决方案时,看到有一个PHP博客的一个回复,大概的意思是可以通过填充非主流的中文占位符”龘“,然后再用中文的计数方式mb_strlen来计数,可以解决中文+英文,甚至中英混搭的字符计数问题。 我将这个思路通过正则进行了代码实现,再加入了处理符号的正则,形成一个基础的处理函数:
// 仿 Word 统计文章字数,和 word 统计出来的一致function comment_count_word($str){    //$str =characet($str);    //判断是否存在替换字符    $is_tihuan_count=substr_count($str,"龘");    try {        //先将回车换行符做特殊处理        $str = preg_replace('/(\r\n+|\s+| +)/',"龘",$str);        //处理英文字符数字,连续字母、数字、英文符号视为一个单词        $str = preg_replace('/[a-z_A-Z0-9-\.!@#\$%\\\^&\*\)\(\+=\{\}\[\]\/",\'<>~`\?:;|]/',"m",$str);        //合并字符m,连续字母、数字、英文符号视为一个单词        $str = preg_replace('/m+/',"*",$str);        //去掉回车换行符        $str = preg_replace('/龘+/',"",$str);        //返回字数        return mb_strlen($str)+$is_tihuan_count;    } catch (Exception $e) {        return 0;    }}
运行了一下,中英文确实可以与word统计一致,不得不说这个方式确实有点骚。 随后我让小七帮我转成Go版本的实现,变成这样:
func CommentCountWord(str string) int {  // check if replacement character exists  isTihuanCount := strings.Count(str, "龘")  defer func() {    if r := recover(); r != nil {      // Here can handle error    }  }()  target := str  // replace return and newline characters with special handling  re := regexp.MustCompile(`(\r\n+|\s+| +| +| +|[\x{00A0}]+)`)  target = re.ReplaceAllString(target, "龘")  // handle English characters and numbers, consecutive letters, numbers, and English symbols are  // considered as one word  re = regexp.MustCompile(`[a-zA-Z0-9\-\.!@\#$%\\\^&\*\(\)+=\{\}\[\]/",\'<>~\?:;|]+`)  target = re.ReplaceAllString(target, "m")  // merge character m, consecutive letters, numbers, and English symbols are considered as one word  re = regexp.MustCompile(`m+`)  target = re.ReplaceAllString(target, "*")  // remove return and newline characters  re = regexp.MustCompile(`龘+`)  target = re.ReplaceAllString(target, "")  // return the count  return utf8.RuneCountInString(target) + isTihuanCount}
到这一步,中英文的适配基本ok了。

六、灵活扩展

前面看起来挺顺利,但是接下来我就碰到难题了。 当我将其他语言的字符串当成参数输入这个函数的时候,不停调整正则的匹配,怎么都不对,甚至影响了原来对于中英的计数。 甚至,当字符串中包含了乱码、emoji等预期之外的字符时,字符计数就开始飘了,囧rz 正当我一筹莫展的时候,在进行葡萄牙语的正则匹配的时候,发现了可以不断进行追加替换的方式,把不同语言的连贯计数的逻辑进行叠加,最后再变成非主流中文进行计数,像这样:

有了葡萄牙语的经验,追加其他语种的计数规则,那还不是手到擒来?

最后,再匹配上目前的emoji符号适配:

语种:简繁中文、英文、葡萄牙语、波兰语、阿拉伯语、波斯语、俄语、西班牙语、土耳其语、法语、希伯来语、意大利语、哈萨克语、泰语、乌克兰语、德语、越南语 外加:目前已知emoji表情符号 基本就构成了整个字符计数器的全部逻辑。

七、完整代码共享

最后附上完整的Go的代码,需要的可以自取:
package word_countimport (  "regexp"  "strings"  "unicode/utf8")// 利用正则提取类word字数统计func CommentCountWord(str string) int {  // check if replacement character exists  isTihuanCount := strings.Count(str, "龘")  defer func() {    if r := recover(); r != nil {      // Here can handle error    }  }()  target := str  // replace return and newline characters with special handling  re := regexp.MustCompile(`(\r\n+|\s+| +| +| +|[\x{00A0}]+)`)  target = re.ReplaceAllString(target, "龘")  // handle English characters and numbers, consecutive letters, numbers, and English symbols are  // considered as one word  re = regexp.MustCompile(`[a-zA-Z0-9\-\.!@\#$%\\\^&\*\(\)+=\{\}\[\]/",\'<>~\?:;|_é“”�]+`)  target = re.ReplaceAllString(target, "m")  // ------ 追加匹配其他语种 start -----  // handle Portuguese characters (葡萄牙语)  re = regexp.MustCompile(`[ãàáêéíóõúçÃÀÁÊÉÍÓÕÚÇ]+`)  target = re.ReplaceAllString(target, "m")  // handle Polish characters (波兰语)  re = regexp.MustCompile(`[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]+`)  target = re.ReplaceAllString(target, "m")  // handle Arabic characters (阿拉伯语)  re = regexp.MustCompile(`[\x{0600}-\x{06FF}]+`)  target = re.ReplaceAllString(target, "m")  // handle Persian(Farsi) characters (波斯语)  re = regexp.MustCompile(`[\x{0600}-\x{06FF}]+`)  target = re.ReplaceAllString(target, "m")  // handle Russian characters (俄语)  re = regexp.MustCompile(`[\x{0400}-\x{04FF}]+`)  target = re.ReplaceAllString(target, "m")  // handle Spanish characters (西班牙语)  re = regexp.MustCompile(`[áéíóúñüÁÉÍÓÚÑÜ¡¿]+`)  target = re.ReplaceAllString(target, "m")  // handle Turkish characters (土耳其语)  re = regexp.MustCompile(`[çğıöşüâîûÇĞIÖŞÜÂÎÛ]+`)  target = re.ReplaceAllString(target, "m")  // handle French characters (法语)  re = regexp.MustCompile(`[éèêëàâæçœôùûüÿÉÈÊËÀÂÆÇŒÔÙÛÜŸ]+`)  target = re.ReplaceAllString(target, "m")  // handle Hebrew characters (希伯来语)  re = regexp.MustCompile(`[\x{0590}-\x{05FF}]+`)  target = re.ReplaceAllString(target, "m")  // handle Italian characters (意大利语)  re = regexp.MustCompile(`[àèéìòùÀÈÉÌÒÙ]+`)  target = re.ReplaceAllString(target, "m")  // handle Kazakh characters (哈萨克语)  re = regexp.MustCompile(`[ӘҒҚҢӨҰҮҺәғқңөұүһ]+`)  target = re.ReplaceAllString(target, "m")  // handle Thai characters (泰语)  re = regexp.MustCompile(`[\x{0E00}-\x{0E7F}]+`)  target = re.ReplaceAllString(target, "m")  // handle Ukrainian characters (乌克兰语)  re = regexp.MustCompile(`[ҐЄЇІїієґ]+`)  target = re.ReplaceAllString(target, "m")  // handle German characters (德语)  re = regexp.MustCompile(`[äöüßÄÖÜẞ]+`)  target = re.ReplaceAllString(target, "m")  // handle Vietnamese characters (越南语)  re = regexp.MustCompile(`[àáảãạăằắẳẵặâầấẩẫậđèéẻẽẹêềếểễệìíỉĩịòóỏõọôồốổỗộơờớởỡợùúủũụưừứửữựỳýỷỹỵÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬĐÈÉẺẼẸÊỀẾỂỄỆÌÍỈĨỊÒÓỎÕỌÔỒỐỔỖỘƠỜỚỞỠỢÙÚỦŨỤƯỪỨỬỮỰỲÝỶỸỴầấẩẫậằắẳẵặềếểễệồốổỗộờớởỡợừứửữựầấẩẫậằắẳẵặềếểễệồốổỗộờớởỡợừứửữự]+`)  target = re.ReplaceAllString(target, "m")  // handle Emoji characters (表情符号)  re = regexp.MustCompile(`[\x{1F300}-\x{1F5FF}\x{1F600}-\x{1F64F}\x{1F680}-\x{1F6FF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}]+`)  target = re.ReplaceAllString(target, "m")  // 此处可以继续拓展其他语种的规则支持  // ------ 追加匹配其他语种 end -----  // merge character m, consecutive letters, numbers, and English symbols are considered as one word  re = regexp.MustCompile(`m+`)  target = re.ReplaceAllString(target, "*")  // remove return and newline characters  re = regexp.MustCompile(`龘+`)  target = re.ReplaceAllString(target, "")  // return the count  return utf8.RuneCountInString(target) + isTihuanCount}