1. 问题描述

我最近需要处理一个 rtf 格式的命名实体识别的数据集,其中包含了一些高亮文本,不同的颜色代表不同的实体类型。我需要提取这些高亮文本,以便后续处理。

rtf 文件中的内容如图所示: rtf文件内容

而 rtf 的格式比较复杂,下面是使用文本编辑器打开的 rtf 文件的内容:

{\rtf1\ansi\ansicpg936\deff0\deflang1033\deflangfe2052{\fonttbl{\f0\fnil\fprq2\fcharset134 \'cb\'ce\'cc\'e5;}{\f1\fswiss\fcharset238{\*\fname Arial;}Arial CE;}}
{\colortbl ;\red0\green0\blue0;\red255\green255\blue255;\red34\green177\blue76;\red255\green242\blue0;\red237\green28\blue36;\red214\green214\blue214;\red185\green122\blue87;\red63\green72\blue204;\red255\green174\blue201;\red163\green73\blue164;}
\viewkind4\uc1\pard\lang2052\f0\fs30\par

\'d7\'d3\'d3\'ce\'a3\'ba\cf5\highlight6\'bf\'d7\'d7\'d3\cf0\highlight0\'b5\'c4\'d6\'f8\'c3\'fb\'b5\'dc\'d7\'d3\'a3\'ac\'d0\'d5\'d1\'d4\'a3\'ac\'c3\'fb\'d9\'c8\'a3\'ac\'d7\'d6\'d7\'d3\'d3\'ce\'a3\'ac\'d2\'e0\'b3\'c6\ldblquote\'d1\'d4\'d3\'ce\rdblquote\ldblquote\'ca\'e5\'ca\'cf\rdblquote\'a3\'ac\cf4\highlight7\'b4\'ba\'c7\'ef\cf0\highlight0\'c4\'a9\cf8\highlight9\b\'ce\'e2\'b9\'fa\cf0\highlight0\b0\'c8\'cb\'a3\'ac\'d3\'eb\cf5\highlight6\'d7\'d3\'cf\'c4\cf0\highlight0\'a1\'a2\cf5\highlight6\b\'d7\'d3\'d5\'c5\cf0\highlight0\b0\'c6\'eb\'c3\'fb\'a3\'ac\ldblquote\cf3\highlight4\ul\b\'bf\'d7\'c3\'c5\'ca\'ae\'d5\'dc\cf0\highlight0\ulnone\b0\rdblquote\'d6\'ae\'d2\'bb\'a3\'ac\'d4\'f8\'ce\'aa\cf8\highlight9\b\'ce\'e4\'b3\'c7\cf2\highlight10\'d4\'d7\cf0\highlight0\b0\'a1\'a3\'d7\'c8\'ba\'f1\'a3\'ba\'d7\'cc\'c8\'f3\'d4\'f3\'b1\'bb\'a1\'a3\'d7\'c8\'a3\'ac\'cd\'a8\ldblquote\'d7\'cc\rdblquote\'a1\'a3\fs18\par
}

可以看到如果直接读取 rtf 文件的内容,工作量会比较大。所以需要借助一些工具来处理 rtf 文件。

2. 方法探索

一开始根据 chatgpt 的建议使用了 Python 的 Rtf15Reader 或者 PyRTF 来处理 rtf 文件,其中使用了 Rtf15Reader 的部分代码如下:

from pyth.plugins.rtf15.reader import Rtf15Reader
from pyth.plugins.plaintext.writer import PlaintextWriter
doc = Rtf15Reader.read(rtf_content)
text = PlaintextWriter.write(doc).getvalue()

但是这两个库都无法正确从我目前使用的 rtf 文件中提取高亮文本,由于我对 rtf 文件的格式也不是很了解,所以我也不知道这两个库无法正确处理的原因。

3. 解决方案

由于直接处理不好处理,所以我打算把 rtf 文件转换成 html 文件,然后再从 html 文件中提取高亮文本,毕竟自己对 html 文件比较了解,更容易提取出其中的高亮文本。

3.1. rtf 转 html

转换方式是使用 Python 的 win32 模块来调用 Word 来打开 rtf 文件并保存为 html 文件。

import os
import win32com.client as win32
import codecs
import shutil

# 创建一个Word应用对象
word = win32.gencache.EnsureDispatch('Word.Application')

# 获取文件夹中的所有文件
rtf_folder = 'rtf'
html_folder = 'html'

if not os.path.exists(html_folder):
    os.makedirs(html_folder)

current_folder = os.path.dirname(os.path.abspath(__file__))

# 先获取一下rtf文件夹的所有文件,防止word打开时出现文件夹中有临时文件的情况
# 不然有可能会报错: pywintypes.com_error: (-2147352567, '发生意外。', (0, 'Microsoft Word', '很抱歉,找不到您的文件。该项目是否已移动、重命名或删除?\r (D:\\projects\\python\\...\\rtf\\~$015.rtf)', 'wdmain11.chm', 24654, -2146823114), None)
rtf_files = os.listdir(rtf_folder)

for filename in rtf_files:
    # 检查文件是否是RTF文件
    if filename.endswith('.rtf'):
        # 获取文件的完整路径
        rtf_path = os.path.join(current_folder, rtf_folder, filename)
        # 打开RTF文件
        doc = word.Documents.Open(rtf_path)
        # 另存为HTML
        wdFormatHTML = 8 # Word的HTML格式常数
        html_path = os.path.join(current_folder, html_folder, filename.replace('.rtf', '.html'))
        try:
            doc.SaveAs(html_path, FileFormat=wdFormatHTML)
            # 关闭文档
            doc.Close()
            # 以UTF-8编码保存HTML文件
            with codecs.open(html_path, 'r', 'gbk') as file:
                content = file.read()
            with codecs.open(html_path, 'w', 'utf-8') as file:
                file.write(content)
        except Exception as e:
            print('Error File:', filename)
            print(e)
            # 移除错误的html文件, 包括xxx.html和xxx.files目录
            os.remove(html_path)
            shutil.rmtree(html_path.replace('.html', '.files'), ignore_errors=True)
            continue

# 关闭Word应用
word.Quit()

上述代码中有两个需要注意的地方:

  1. 由于 Word 打开 rtf 文件时会生成一些临时文件,如果直接使用os.listdir获取文件列表,可能会读取到这些临时文件从而导致报错,所以需要先获取文件列表,然后再处理文件。
  2. 由于 Word 保存 html 文件时默认使用 gbk 编码,不方便后续处理,所以需要读取后再以 utf-8 编码保存。此时如果使用浏览器打开 html 文件,可能会出现乱码,但是不影响后续处理。

3.2. 提取高亮文本

读取上一步生成的 html 文件,解析带有background属性的span标签,提取其中的文本。html 的格式大概如下:

<div class="WordSection1">
    <p class="MsoNormal">
        <span>other text</span>
        <!-- 高亮文本在b标签内 -->
        <b>
            <span style="..."></span>
        </b>
    </p>
</div>

在读取 html 方面,js 具有一定优势,所以采用 nodejs 来处理 html 文件。cheerio 是一个类似 jQuery 的库,可以方便地解析 html 文件。

const fs = require("fs");
const path = require("path");
const cheerio = require("cheerio");

function parseHtmlFile(filePath) {
  const content = fs.readFileSync(filePath, "utf-8");
  const $ = cheerio.load(content);

  let wordIndex = 0;
  let allText = "";
  // 高亮颜色与类型的对应关系
  const colorMap = {
    silver: "人物",
    yellow: "知识点",
    aqua: "朝代",
    red: "文献",
    lime: "事件",
    gray: "官职",
    fuchsia: "地名",
  };
  const resultMap = {};

  $(".MsoNormal")
    .children()
    .each((i, elem) => {
      // 依次遍历子元素
      const text = $(elem).text();
      allText += text;
      if (elem.name === "b") {
        // 找到高亮文本
        const span = $(elem).find("span");
        const style = span.attr("style");
        const match = style.match(/background:(.*?)(;|$)/); // (;|$) 匹配分号或者字符串结尾
        if (match) {
          let color = match[1];
          color = colorMap[color] || color;
          const spanText = span.text();
          if (!resultMap[color]) {
            resultMap[color] = [];
          }
          resultMap[color].push({
            text: spanText,
            start: wordIndex,
            end: wordIndex + spanText.length,
          });
        }
      }
      wordIndex += text.length;
    });

  return {
    content: allText,
    resultMap: resultMap,
  };
}

function parseHtmlFilesInDirectory(dirPath) {
  const files = fs.readdirSync(dirPath);
  const results = [];

  for (const file of files) {
    if (path.extname(file) === ".html") {
      const filePath = path.join(dirPath, file);
      const result = parseHtmlFile(filePath);
      results.push({ file, result });
    }
  }

  return results;
}

const dirPath = "html";
const results = parseHtmlFilesInDirectory(dirPath);

/**
 * 输出示例结果
 * @param {*} results
 */
function getExampleOutput(results) {
  const outputDir = "html-output";
  if (!fs.existsSync(outputDir)) {
    fs.mkdirSync(outputDir);
  }
  // 写入到文件, utf-8 编码格式
  // fs.writeFileSync('result.json', JSON.stringify(results, null, 2), 'utf-8');
  for (const { file, result } of results) {
    fs.writeFileSync(
      path.join(outputDir, file.replace(".html", ".json")),
      JSON.stringify(result, null, 2),
      "utf-8"
    );
  }
}

/**
 * 输出doccano格式结果, jsonl格式
 * {"id": 1, "text": "text", "labels": [[0, 4, "label"]]}
 * @param {*} results
 */
function getDoccanoOutput(results) {
  const doccanoOutputDir = "doccano-output";
  if (!fs.existsSync(doccanoOutputDir)) {
    fs.mkdirSync(doccanoOutputDir);
  }
  const jsonlData = [];
  for (const { file, result } of results) {
    const id = file.replace(".html", "");
    const { content, resultMap } = result;
    const labels = [];
    for (const key in resultMap) {
      const items = resultMap[key];
      for (const item of items) {
        labels.push([item.start, item.end, key]);
      }
    }
    // 按照起始位置排序
    labels.sort((a, b) => a[0] - b[0]);
    jsonlData.push({
      id,
      text: content,
      label: labels,
      Comments: [],
    });
  }
  fs.writeFileSync(
    path.join(doccanoOutputDir, "data.jsonl"),
    jsonlData.map((item) => JSON.stringify(item)).join("\n"),
    "utf-8"
  );
}

// getExampleOutput(results);
getDoccanoOutput(results);

检测背景色采用了正则表达式,匹配background属性的值,然后根据颜色值来判断高亮文本的类型。上面的colorMap是颜色值与类型的对应关系,是通过事先输出所有的颜色和对应的文本,从而分析出来的。