文档解析器:支持 PDF、DOCX、Markdown

文档解析器:支持 PDF、DOCX、Markdown

本文是《从零构建 InkWords:AI 驱动的技术博客生成器》系列的第 14 章。本系列完整源码可在 GitHub 仓库 获取。

引言:为什么需要文档解析器?

想象一下,你正在准备一篇技术博客,手头有各种格式的资料:一份 PDF 格式的官方 API 文档、一个 Word 文档写的设计稿、以及几个 Markdown 格式的笔记。如果有一个工具能自动帮你把这些不同格式的内容统一提取成纯文本,然后交给 AI 去整理润色,那该多省事!

这正是 InkWords 文档解析器 (DocParser) 的使命。它作为整个系统的"前哨",负责将用户上传的各种格式文件(PDF、DOCX、Markdown/TXT)转换成统一的纯文本格式,为后续的 AI 分析和博客生成铺平道路。

本章,我们将深入剖析 DocParser 的设计与实现,看看它如何优雅地处理多种文件格式,并贯彻"阅后即焚"的安全策略。

核心架构:接口与实现

在 Go 语言中,接口 (interface) 是定义行为契约的绝佳方式。InkWords 的解析器模块首先定义了一个通用的 Parser 接口:

go 复制代码
// Parser defines the interface for all document parsers
type Parser interface {
    Parse(src io.Reader, filename string) (string, error)
}

这个接口非常简洁,只包含一个 Parse 方法。它接收一个数据流 (io.Reader) 和文件名,返回提取出的文本字符串或一个错误。这种设计带来了两大好处:

  1. 灵活性 :任何实现了 Parse 方法的结构体都可以成为解析器,方便未来扩展(比如支持 PPT、Excel)。
  2. 统一性 :无论底层处理的是 PDF 还是 DOCX,对上层调用者来说,都是同一个 Parse 方法,大大降低了使用复杂度。

我们的主角 DocParser 结构体就实现了这个接口,它是一个"多面手",内部集成了对多种格式的处理逻辑。

go 复制代码
// DocParser implements Parser interface for PDF and Markdown files
type DocParser struct{}

// NewDocParser creates a new instance of DocParser
func NewDocParser() *DocParser {
    return &DocParser{}
}

解析流程总览:"阅后即焚"策略

DocParserParse 方法是整个解析过程的总调度中心。它的核心流程可以用下面的时序图来清晰展示:
具体格式解析器(PDF/DOCX/Text) 临时文件 Parse方法 用户/上游服务 具体格式解析器(PDF/DOCX/Text) 临时文件 Parse方法 用户/上游服务 7. 函数返回后,defer语句自动删除临时文件 调用Parse(数据流, 文件名) 1. 创建临时文件 2. 写入数据流 3. 刷新并重置指针 4. 根据后缀名路由 5. 返回提取的文本 6. 返回纯文本结果

这个流程中,最精妙的设计莫过于 "阅后即焚" (Burn After Reading) 策略。用户上传的文件内容在处理完成后会立即从服务器磁盘上删除,不留痕迹。这是如何实现的呢?关键就在 defer 语句和临时文件操作上。

让我们结合代码逐段分析:

go 复制代码
func (p *DocParser) Parse(src io.Reader, filename string) (string, error) {
    // 1. 获取文件后缀名,用于后续路由判断
    ext := strings.ToLower(filepath.Ext(filename))

    // 2. 创建临时文件。这是"阅后即焚"的载体。
    //    os.CreateTemp 会在系统临时目录生成唯一文件名,如 `/tmp/inkwords-parse-123456.pdf`
    tempFile, err := os.CreateTemp("", "inkwords-parse-*"+ext)
    if err != nil {
        return "", fmt.Errorf("failed to create temp file: %w", err)
    }
    // 3. 核心安全措施:使用 defer 确保函数退出时(无论成功或失败)一定执行清理。
    defer func() {
        tempFile.Close()                 // 关闭文件句柄
        os.Remove(tempFile.Name())       // 删除临时文件
    }()

    // 4. 将上传的数据流拷贝到临时文件
    size, err := io.Copy(tempFile, src)
    if err != nil {
        return "", fmt.Errorf("failed to write to temp file: %w", err)
    }

    // 5. 确保数据从缓冲区完全写入磁盘,避免后续读取时数据不完整
    if err := tempFile.Sync(); err != nil {
        return "", fmt.Errorf("failed to sync temp file: %w", err)
    }

    // 6. 将文件指针重置回开头,因为 io.Copy 后指针在文件末尾
    if _, err := tempFile.Seek(0, 0); err != nil {
        return "", fmt.Errorf("failed to seek temp file: %w", err)
    }

    // 7. 根据文件后缀,分发给对应的具体解析函数
    switch ext {
    case ".pdf":
        return p.parsePDF(tempFile, size)
    case ".md", ".markdown", ".txt":
        return p.parsePlainText(tempFile)
    case ".docx":
        return p.parseDocx(tempFile)
    default:
        return "", fmt.Errorf("unsupported file extension: %s", ext)
    }
}

生活化比喻:这个过程就像一个高效的快递分拣中心。

  1. 你(用户)送来一个包裹(数据流),上面贴着标签"PDF"(文件名)。
  2. 分拣中心(Parse方法)准备了一个临时货架(临时文件)来存放它。
  3. 包裹被放上货架后,系统根据标签决定将它送往"PDF处理线"。
  4. 处理线工人(parsePDF函数)拆开包裹,取出里面的信件(文本内容)。
  5. 关键一步 :信件被取出后,系统立即销毁 临时货架和空包裹(defer删除文件),绝不保留用户隐私。

分格式解析:各显神通

总调度完成后,文件会被路由到三个具体的解析函数。它们分别针对不同格式,调用了专门的第三方库。

1. 解析 PDF 文件

PDF 解析使用了 github.com/ledongthuc/pdf 这个纯 Go 实现的库。它的优点是无需依赖外部的 PDF 渲染引擎(如 Poppler)。

go 复制代码
func (p *DocParser) parsePDF(file *os.File, size int64) (string, error) {
    // 传入已打开的文件指针和文件大小,创建PDF阅读器
    reader, err := pdf.NewReader(file, size)
    if err != nil {
        // 友好地处理常见错误:损坏的PDF文件
        if strings.Contains(err.Error(), "missing %%EOF") {
            return "", fmt.Errorf("解析失败:该文件似乎已损坏或不是标准的PDF格式")
        }
        return "", fmt.Errorf("解析 PDF 失败: %w", err)
    }

    var buf bytes.Buffer
    // 调用库方法获取纯文本内容,返回的是一个 io.Reader
    b, err := reader.GetPlainText()
    if err != nil {
        return "", fmt.Errorf("提取 PDF 文本失败: %w", err)
    }
    // 将文本读取到缓冲区
    buf.ReadFrom(b)

    // 去除首尾空白字符后返回
    return strings.TrimSpace(buf.String()), nil
}

2. 解析 DOCX 文件

DOCX 文件本质是一个 ZIP 压缩包,里面包含了 XML 格式的文档内容、样式等。我们使用 github.com/nguyenthenguyen/docx 库来处理它。

这里有个小陷阱:该库需要通过文件路径 来打开文档,而不是我们已经打开的 *os.File 指针。因此我们需要先获取临时文件的路径 (file.Name())。

go 复制代码
func (p *DocParser) parseDocx(file *os.File) (string, error) {
    // 通过文件路径打开DOCX文档
    doc, err := docx.ReadDocxFile(file.Name())
    if err != nil {
        return "", fmt.Errorf("failed to open docx file: %w", err)
    }
    defer doc.Close() // 记得关闭文档资源

    // 获取可编辑对象并提取内容
    text := doc.Editable().GetContent()
    // 由于GetContent()可能返回带XML标签的内容,我们需要一个简单的清洗函数
    text = stripXMLTags(text)
    return strings.TrimSpace(text), nil
}

// stripXMLTags 是一个简单的辅助函数,用于剥离XML标签
func stripXMLTags(content string) string {
    var buf bytes.Buffer
    inTag := false // 标记当前字符是否位于XML标签内部
    for _, r := range content {
        if r == '<' {
            inTag = true // 遇到'<',进入标签
        } else if r == '>' {
            inTag = false // 遇到'>',离开标签
        } else if !inTag {
            // 只有不在标签内的字符才被保留
            buf.WriteRune(r)
        }
    }
    return buf.String()
}

3. 解析纯文本文件 (Markdown/TXT)

这是最简单的情况,我们只需要将文件内容原样读取出来即可。

go 复制代码
func (p *DocParser) parsePlainText(file *os.File) (string, error) {
    // 确保文件指针在开头
    if _, err := file.Seek(0, 0); err != nil {
        return "", fmt.Errorf("failed to seek file: %w", err)
    }

    var buf bytes.Buffer
    // 将文件内容直接拷贝到缓冲区
    if _, err := io.Copy(&buf, file); err != nil {
        return "", fmt.Errorf("failed to read plain text file: %w", err)
    }

    return strings.TrimSpace(buf.String()), nil
}

如何在你的项目中实践?

如果你想在自己的 Go 项目中集成类似的多格式文档解析功能,可以遵循以下步骤:

  1. 初始化项目:创建一个新的 Go 模块。

    bash 复制代码
    mkdir my-doc-parser && cd my-doc-parser
    go mod init github.com/yourname/my-doc-parser
  2. 安装依赖:引入本文提到的两个第三方库。

    bash 复制代码
    go get github.com/ledongthuc/pdf
    go get github.com/nguyenthenguyen/docx
  3. 复制代码 :将本章分析的 DocParser 相关代码(包括 Parser 接口、DocParser 结构体及其所有方法)复制到你的项目文件中,例如 parser/doc_parser.go

  4. 编写测试 :创建一个简单的 main.go 来测试功能。

    go 复制代码
    package main
    
    import (
        "fmt"
        "log"
        "os"
        "path/filepath"
        // 假设你的parser包放在 ./parser 目录下
        "github.com/yourname/my-doc-parser/parser"
    )
    
    func main() {
        dp := parser.NewDocParser()
    
        // 测试一个PDF文件
        pdfFile, _ := os.Open("sample.pdf")
        defer pdfFile.Close()
    
        text, err := dp.Parse(pdfFile, filepath.Base(pdfFile.Name()))
        if err != nil {
            log.Fatal("PDF解析失败:", err)
        }
        fmt.Printf("解析成功,前100字符:\n%.100s...\n", text)
    }
  5. 运行测试

    bash 复制代码
    go run main.go

总结与展望

本章我们深入探讨了 InkWords 的文档解析核心 ------ DocParser。它通过清晰的接口设计、统一的处理流程、"阅后即焚"的安全策略以及对多种格式的适配,为系统提供了稳定可靠的数据输入能力。

核心要点回顾

  • 接口化设计 :通过 Parser 接口实现了解析器的可扩展和易用性。
  • 策略模式Parse 方法作为调度中心,根据文件后缀将任务分发给不同的具体解析器。
  • 安全第一 :使用临时文件和 defer 语句确保用户文件内容在处理后被立即销毁。
  • 依赖优秀库 :合理利用成熟的开源库 (ledongthuc/pdf, nguyenthenguyen/docx) 处理复杂格式,避免重复造轮子。

DocParser 目前完美解决了单篇本地文档 的解析问题。然而,在实际的技术学习场景中,我们更常面对的是完整的、结构复杂的 Git 代码仓库。如何自动克隆一个仓库,分析其项目结构,并智能地将其拆解成一系列连贯的技术博客呢?

下期预告:Git 仓库抓取与内容提取

在下一章,我们将揭开 InkWords 更强大的能力。你将看到系统如何实现 GitFetcher,从 GitHub/Gitee 等平台抓取源码;如何过滤无关文件;以及如何为庞大的项目评估复杂度,并规划出系列博客的生成大纲。敬请期待!

相关推荐
暗不需求2 小时前
# 深入 React Todos:从零实现一个状态提升与本地持久化的待办应用
javascript·react.js·全栈
骑自行车的码农2 小时前
数据的源头 —— JSX
react.js
时光足迹4 小时前
Tiptap 简单编辑器模版
前端·javascript·react.js
时光足迹4 小时前
Tiptap编辑器
前端·javascript·react.js
时光足迹4 小时前
电子书阅读器之笔记高亮(跨段处理)
前端·javascript·react.js
空中海6 小时前
03 渲染机制、性能优化与现代 React
javascript·react.js·性能优化
梵克之泪8 小时前
批量拆分PDF只取PDF的首页,批量按文件页数拆分PDF,按卷内目录页码表计算批量拆分分割PDF
pdf·pdf拆分
openKaka_9 小时前
为什么 React 18 之后使用 createRoot,而不是 ReactDOM.render
前端·javascript·react.js
老王以为10 小时前
从源码到架构:React useActionState 深度剖析
前端·javascript·react.js