别再手动改文档了!5 个文档处理工具的技术实现(Word 合并/模板填充/批量重命名/加密/Markdown)

32 阅读14分钟

每天被 Word 合并拆分、文件批量重命名、模板填充这些重复操作消耗的时间,算下来一年可能有几十个小时。这篇文章从技术实现的角度,拆解这 5 个在线文档工具的底层方案,包含 python-docx、Web Crypto API、CodeMirror 6 的实际应用。

封面图_5款免费文档处理工具.png


写在前面

工作中经常需要处理各种文档,以下场景应该不陌生:

  • 目录里有 50 个文件需要按规则重命名,一个个 F2 改到手酸
  • 合同文档要把特定几页拆出来单独保存,手动复制粘贴还容易丢格式
  • 批量生成 100 份带不同姓名的邀请函,一个一个改到怀疑人生
  • 共享敏感文件时,找个靠谱的加密工具还要装一堆软件

这些需求都有一个共同点:操作逻辑极其简单,但手动做起来极其耗时

围绕这些场景,我在做在线工具平台时把它们都实现了出来。下面逐个拆解技术方案和实现细节。


工具一:Word 合并拆分

使用场景:合同按页拆分、多份报告合并成一份、PDF 文档按范围提取页面。

这个功能的技术选型有一个关键决策点:纯前端处理还是走服务端?

方案对比

方案优势劣势
纯前端(pdf-lib / mammoth.js)文件不上传服务器,隐私好大文件内存溢出风险,兼容性差
服务端(python-docx / PyPDF2)处理稳定,支持大文件需要上传文件,有网络开销
混合方案小文件走前端,大文件走服务端逻辑分支多,维护成本高

我最终选择了服务端方案,核心原因是:用户上传的文档格式千奇百怪,.doc 和 .docx 内部结构完全不同,纯前端库对老格式的支持非常有限。服务端用 Python 生态可以统一处理。

合并的核心实现

Word 合并的底层原理其实很简单:读取多个 Word 文档的内容,逐个追加到目标文档中。用 python-docx 实现:

from docx import Document
import os

def merge_word_docs(file_paths: list[str], output_path: str):
    """合并多个 Word 文档为一个"""
    merged = Document()

    for i, path in enumerate(file_paths):
        doc = Document(path)

        # 如果不是第一个文档,插入分页符
        if i > 0:
            merged.add_page_break()

        # 逐个复制段落和格式
        for paragraph in doc.paragraphs:
            new_para = merged.add_paragraph()
            # 复制段落文本
            new_para.text = paragraph.text
            # 复制段落样式
            if paragraph.style:
                new_para.style = paragraph.style
            # 复制对齐方式
            new_para.alignment = paragraph.alignment

        # 复制表格
        for table in doc.tables:
            new_table = merged.add_table(
                rows=len(table.rows),
                cols=len(table.columns)
            )
            # 逐单元格复制内容
            for i, row in enumerate(table.rows):
                for j, cell in enumerate(row.cells):
                    new_table.cell(i, j).text = cell.text

        # 复制图片(遍历 document.xml 中的图片关系)
        for rel in doc.part.rels.values():
            if "image" in rel.reltype:
                merged.part.rels.get_or_add(rel)

    merged.save(output_path)

拆分的核心实现

拆分比合并更简单 —— 本质上是读取源文档,按指定页码范围生成多个新文档

from docx import Document

def split_word_by_range(
    input_path: str,
    ranges: list[tuple[int, int]]
) -> list[str]:
    """
    按页码范围拆分 Word 文档
    ranges: [(1, 3), (4, 7)] 表示提取第1-3页和第4-7页
    """
    source = Document(input_path)
    output_paths = []

    for idx, (start, end) in enumerate(ranges):
        new_doc = Document()

        # 注意:python-docx 没有原生的按页读取功能
        # 实际方案是通过 page-break 标记来定位
        current_page = 1
        for element in source.element.body:
            tag = element.tag.split('}')[-1] if '}' in element.tag else element.tag

            if tag == 'p':  # 段落
                para_elem = element
                # 检测分页符
                if current_page >= start and current_page <= end:
                    p = new_doc.add_paragraph()
                    p.text = para_elem.text if hasattr(para_elem, 'text') else ''
            elif tag == 'tbl':  # 表格
                if current_page >= start and current_page <= end:
                    # 复制表格逻辑,同上
                    pass

            # 检测分页标记
            if check_page_break(element):
                current_page += 1

        output_path = f"{input_path}_part_{start}_{end}.docx"
        new_doc.save(output_path)
        output_paths.append(output_path)

    return output_paths

实际应用中的一个坑:python-docx 对 .doc(旧格式,OLE 复合文档)不支持,只支持 .docx(OpenXML 格式)。用户可以上传 .doc 文件,但服务端需要先用 LibreOffice 或 win32com 做一次格式预转换。在工具页面上加一个"仅支持 .docx 格式"的提示,能省掉大量用户困惑。

Word合并拆分 - 操作页面.png


工具二:文件批量重命名

使用场景:照片按日期重命名、代码文件统一加前缀、清理下载目录里的乱码文件名。

这个工具是 5 个里面唯一一个完全不需要后端参与的工具 —— 因为浏览器本身就有操作本地文件系统的能力。当然,这里说的"操作"并不是直接修改文件名,而是让用户把文件拖进来,设置好规则,然后打包下载重命名后的文件

核心原理

用户拖入文件 → 浏览器读取文件名列表
              → 用户设置规则(前缀/后缀/替换/序号等)
              → 生成新文件名预览
              → 用户确认 → 将文件按新文件名打包 ZIP → 下载

命名规则引擎设计

一个灵活的批量重命名工具,核心在于规则引擎的设计。我拆解了用户最常见的需求,总结出 5 种规则类型:

规则类型        示例                         适用场景
─────────────────────────────────────────────────────
添加前缀/后缀    report_XX.docx → 最终_report_XX.docx    分类归档
替换文本        IMG_20240501.jpg → photo_20240501.jpg    去品牌前缀
序号模板        file.jpg → DSC_001.jpg, DSC_002.jpg...  照片整理
正则替换        abc-123.txt → abc_123.txt                清理特殊字符
大小写转换      Report.DOCX → report.docx                统一格式

规则引擎的核心代码其实不复杂,关键是对用户友好:

// 规则引擎核心
class RenameEngine {
  applyRules(originalName, rules) {
    let { base, ext } = this.splitName(originalName);
    let counter = 0;

    for (const rule of rules) {
      switch (rule.type) {
        case 'prefix':
          base = rule.value + base;
          break;
        case 'suffix':
          base = base + rule.value;
          break;
        case 'replace':
          base = base.replaceAll(rule.from, rule.to);
          break;
        case 'serial':
          // 序号规则:{N} 表示从 N 开始的递增数字
          // {N:03} 表示 3 位补零
          counter++;
          const pad = rule.padding || 1;
          const num = String(rule.start + counter - 1)
            .padStart(pad, '0');
          base = rule.template.replace('{n}', num);
          break;
        case 'regex':
          base = base.replace(
            new RegExp(rule.pattern, rule.flags),
            rule.replacement
          );
          break;
        case 'case':
          base = rule.to === 'upper' ? base.toUpperCase()
                : rule.to === 'lower' ? base.toLowerCase()
                : base;
          break;
      }
    }

    return base + ext;
  }

  splitName(filename) {
    const dot = filename.lastIndexOf('.');
    if (dot === -1) return { base: filename, ext: '' };
    return {
      base: filename.slice(0, dot),
      ext: filename.slice(dot)
    };
  }
}

预览机制:改名前先看清楚

批量重命名最怕的就是"手滑改错"—— 100 个文件一键改完发现命名错误,连回退都找不着原文件名。所以一定要做改前预览

// 生成预览表格数据
function generatePreview(files, rules) {
  const engine = new RenameEngine();

  return files.map((file, idx) => ({
    index: idx + 1,
    original: file.name,
    renamed: engine.applyRules(file.name, rules),
    size: formatFileSize(file.size),
    conflict: false  // 同名冲突检测
  }));
}

// 冲突检测:如果有两个文件改名后同名,高亮提示
function detectConflicts(preview) {
  const nameMap = new Map();
  preview.forEach(item => {
    const count = nameMap.get(item.renamed) || 0;
    nameMap.set(item.renamed, count + 1);
  });
  preview.forEach(item => {
    item.conflict = nameMap.get(item.renamed) > 1;
  });
  return preview;
}

打包下载

最后一步是用 JSZip 把所有文件按新文件名打包:

import JSZip from 'jszip';

async function downloadRenamed(files, rules) {
  const zip = new JSZip();
  const engine = new RenameEngine();

  for (const file of files) {
    const newName = engine.applyRules(file.name, rules);
    const buffer = await file.arrayBuffer();
    zip.file(newName, buffer);
  }

  const blob = await zip.generateAsync({ type: 'blob' });
  const url = URL.createObjectURL(blob);

  const a = document.createElement('a');
  a.href = url;
  a.download = 'renamed_files.zip';
  a.click();

  URL.revokeObjectURL(url);
}

批量重命名看起来简单,但要做好用户体验,预览和冲突检测这两个环节一个都不能省。

文件批量重命名 - 操作页面.png


工具三:文档模板填充

使用场景:批量生成证书、合同、邀请函、工牌、成绩单等带变量的文档。

这个功能在企业场景里需求量极大。想象一下:人事要给 200 个员工发生日祝福邮件(Word 附件),每个附件的正文内容一样,只有姓名和日期不同。手动改 200 份?

核心原理

文档模板填充本质上是一个变量替换引擎。流程如下:

Word 模板(含占位符) + 数据源(Excel/CSV/手动输入)
        ↓
  变量解析 → 逐条替换占位符
        ↓
  生成 N 份独立文档 → 打包下载

占位符设计

首先要定义占位符的格式。常见的方案有三种:

方案示例优势劣势
{变量名}{姓名}同学简单直观可能与正文中的花括号冲突
${变量名}${姓名}同学模板引擎通用语法对非技术用户不够友好
{{变量名}}{{姓名}}同学不容易与正文冲突略长

我最终选择了 {{变量名}} 方案,并且在工具界面做了自动检测占位符的功能——用户上传模板后,自动扫描文档中所有 {{xxx}},在右侧数据面板生成对应的输入列。

后端实现

用 python-docx 处理占位符替换,关键是要处理占位符可能跨多个 Run 的情况:

from docx import Document
import re

def fill_template(template_path: str, data: dict, output_path: str):
    """用数据字典填充 Word 模板的占位符"""
    doc = Document(template_path)

    # 构建占位符替换映射
    placeholder_pattern = re.compile(r'\{\{(.+?)\}\}')

    # 替换段落中的占位符
    for paragraph in doc.paragraphs:
        for run in paragraph.runs:
            matches = placeholder_pattern.findall(run.text)
            for key in matches:
                if key.strip() in data:
                    run.text = run.text.replace(
                        f'{{{{{key}}}}}',
                        str(data[key.strip()])
                    )

    # 替换表格中的占位符
    for table in doc.tables:
        for row in table.rows:
            for cell in row.cells:
                for paragraph in cell.paragraphs:
                    for run in paragraph.runs:
                        matches = placeholder_pattern.findall(run.text)
                        for key in matches:
                            if key.strip() in data:
                                run.text = run.text.replace(
                                    f'{{{{{key}}}}}',
                                    str(data[key.strip()])
                                )

    doc.save(output_path)

批量填充的优化

当数据量大(比如几万条)时,逐条生成会很慢。优化策略是使用线程池并发处理

from concurrent.futures import ThreadPoolExecutor, as_completed
import os

def batch_fill(template_path: str, data_list: list[dict], output_dir: str):
    """批量填充,利用多线程加速"""
    max_workers = min(os.cpu_count() or 4, len(data_list))
    results = []

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {}

        for idx, data in enumerate(data_list):
            output_path = os.path.join(output_dir, f'result_{idx + 1}.docx')
            future = executor.submit(
                fill_template, template_path, data, output_path
            )
            futures[future] = idx

        for future in as_completed(futures):
            idx = futures[future]
            try:
                future.result()
                results.append({'index': idx, 'status': 'success'})
            except Exception as e:
                results.append({'index': idx, 'status': 'error', 'error': str(e)})

    return results

上传 Excel 数据源的思路

很多用户习惯用 Excel 管理数据。让用户上传一个 Excel 表,第一行是变量名(表头),后续行是数据,比手动逐行输入高效太多:

import openpyxl

def parse_data_from_excel(excel_path: str) -> list[dict]:
    """从 Excel 文件解析数据列表"""
    wb = openpyxl.load_workbook(excel_path)
    ws = wb.active

    # 第一行为表头(变量名)
    headers = [cell.value for cell in ws[1]]

    data_list = []
    for row in ws.iter_rows(min_row=2, values_only=True):
        if any(row):  # 跳过空行
            data = {headers[i]: str(row[i]) if row[i] else ''
                    for i in range(len(headers))}
            data_list.append(data)

    return data_list

文档模板填充 - 操作页面.png


工具四:文件加密

使用场景:敏感文档分享前的加密保护、合同文件的安全传输、个人隐私文件的本地加密。

文件加密这个需求说大不大,说小不小。偶尔需要给一份文件加个密码,专门装一个加密软件太麻烦,不加密直接发又不够安全。

技术选型:纯前端加密为什么可行?

这个工具的特别之处在于 —— 加密和解密都可以在浏览器端完成,文件不需要上传到服务器。这对于隐私敏感的场景是绝对的加分项。

核心依赖浏览器原生提供的 Web Crypto API

// 使用 Web Crypto API 的 AES-GCM 加密
async function encryptFile(file, password) {
  // 1. 用 PBKDF2 从密码派生密钥
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(password),
    { name: 'PBKDF2' },
    false,
    ['deriveKey']
  );

  const key = await crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: salt,
      iterations: 100000,   // 10万次迭代,抗暴力破解
      hash: 'SHA-256',
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt']
  );

  // 2. 用 AES-GCM 加密文件内容
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const fileBuffer = await file.arrayBuffer();

  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv: iv },
    key,
    fileBuffer
  );

  // 3. 打包:salt + iv + 密文 → 一个文件
  const result = new Uint8Array(
    salt.length + iv.length + encrypted.byteLength
  );
  result.set(salt, 0);
  result.set(iv, salt.length);
  result.set(
    new Uint8Array(encrypted),
    salt.length + iv.length
  );

  return new Blob([result], { type: 'application/octet-stream' });
}

对应的解密逻辑:

async function decryptFile(encryptedBlob, password) {
  const buffer = await encryptedBlob.arrayBuffer();
  const data = new Uint8Array(buffer);

  // 从加密文件中提取 salt、iv 和密文
  const salt = data.slice(0, 16);
  const iv = data.slice(16, 28);
  const ciphertext = data.slice(28);

  // 用相同的 PBKDF2 参数重新派生密钥
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(password),
    { name: 'PBKDF2' },
    false,
    ['deriveKey']
  );

  const key = await crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: salt,
      iterations: 100000,
      hash: 'SHA-256',
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['decrypt']
  );

  // 解密
  const decrypted = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: iv },
    key,
    ciphertext
  );

  return new Blob([decrypted]);
}

AES-GCM vs AES-CBC 的选择

特性AES-GCMAES-CBC
加密模式认证加密(AEAD)普通块加密
完整性校验内置(自动检测篡改)需要额外 HMAC
性能支持硬件加速(AES-NI)同样支持
IV 要求12 字节随机数16 字节随机数
推荐度✅ 优先选择仅在兼容旧系统时使用

选择 GCM 的核心原因:它提供了认证加密——如果有人篡改了密文,解密时会直接报错,而不是默默解出一堆乱码。这一点在实际应用中非常重要。

安全性说明

坦白说,浏览器端加密的安全性取决于用户的密码强度。如果密码是 123456,那什么加密算法也救不了。所以界面上需要做一个密码强度检测

function checkPasswordStrength(password) {
  let score = 0;

  if (password.length >= 8) score++;
  if (password.length >= 12) score++;
  if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
  if (/\d/.test(password)) score++;
  if (/[^a-zA-Z0-9]/.test(password)) score++;

  return score >= 4 ? 'strong'
       : score >= 3 ? 'medium'
       : 'weak';
}

文件加密 - 操作页面.png


工具五:Markdown 编辑器

使用场景:写技术文档、写 README、写博客草稿、写项目周报。

Markdown 编辑器可能是这 5 个工具里用户留存时间最长的一个——别的工具用了就走,这个工具用户会打开着写半天。

编辑器的技术选型

市面上成熟的 Markdown 编辑器方案很多,核心选型考虑三点:

编辑器方案包体积扩展性上手难度
CodeMirror 6~150KB极强
Monaco Editor~3MB+极强
EasyMDE (SimpleMDE)~80KB有限
自研 contenteditable<50KB完全可控

Monaco 功能最全但体积太大,不适合主打轻量的在线工具。CodeMirror 6 的模块化做得非常好,按需引入核心功能只有 150KB 左右,最后选定的方案是 CodeMirror 6 + marked.js

实时预览架构

编辑器输入(CodeMirror 6)
    │
    ├─→ 防抖 300ms(debounce)
    │       │
    │       └─→ marked.js 解析
    │               │
    │               └─→ DOMPurify 安全过滤
    │                       │
    │                       └─→ 渲染到预览面板
    │
    └─→ 本地 localStorage 自动保存(防丢失)

核心代码实现:

import { basicSetup } from 'codemirror';
import { markdown } from '@codemirror/lang-markdown';
import { EditorView, keymap } from '@codemirror/view';
import { oneDark } from '@codemirror/theme-one-dark';
import { marked } from 'marked';
import DOMPurify from 'dompurify';

class MarkdownEditor {
  constructor(container, previewContainer) {
    // 初始化编辑器
    this.view = new EditorView({
      doc: this.loadDraft() || '# 开始写作\n\n',
      extensions: [
        basicSetup,
        markdown(),
        oneDark,
        keymap.of([...this.customKeymap()]),
        EditorView.updateListener.of((update) => {
          if (update.docChanged) {
            this.onContentChange(update.state.doc.toString());
          }
        }),
      ],
      parent: container,
    });

    this.previewEl = previewContainer;
    this.renderPreview(this.view.state.doc.toString());
  }

  // 防抖渲染预览
  onContentChange = this.debounce((content) => {
    this.renderPreview(content);
    this.autoSave(content);
  }, 300);

  renderPreview(markdownText) {
    const html = marked.parse(markdownText, {
      gfm: true,          // GitHub Flavored Markdown
      breaks: true,       // 换行即 <br>
    });
    // XSS 防护:过滤所有危险 HTML
    this.previewEl.innerHTML = DOMPurify.sanitize(html);
  }

  autoSave(content) {
    localStorage.setItem('md-editor-draft', content);
    localStorage.setItem('md-editor-draft-time', Date.now());
  }

  loadDraft() {
    return localStorage.getItem('md-editor-draft');
  }

  // 自定义快捷键
  customKeymap() {
    return [
      { key: 'Ctrl-b', run: () => this.wrapSelection('**') },
      { key: 'Ctrl-i', run: () => this.wrapSelection('_') },
      { key: 'Ctrl-`', run: () => this.wrapSelection('`') },
    ];
  }

  // 选中文本包裹
  wrapSelection(wrapper) {
    const { from, to } = this.view.state.selection.main;
    const text = this.view.state.sliceDoc(from, to);
    this.view.dispatch({
      changes: {
        from, to,
        insert: `${wrapper}${text}${wrapper}`,
      }
    });
    return true;
  }

  debounce(fn, delay) {
    let timer;
    return (...args) => {
      clearTimeout(timer);
      timer = setTimeout(() => fn.apply(this, args), delay);
    };
  }
}

导出功能:为什么需要多格式?

写好的 Markdown,用户通常需要导出到不同场景使用:

.md  → 保存源码,后续继续编辑
.html → 直接复制到 CMS 后台发布
.docx → 提交给不用 Markdown 的同事审阅
.pdf  → 正式交付、打印

导出到 Word 这里有个有趣的技术点 —— 用 Markdown 转 HTML,再用 HTML 转 Word:

// Markdown 直接导出为 .docx
async function exportToWord(markdownContent, filename) {
  // 1. Markdown → HTML
  const html = marked.parse(markdownContent);

  // 2. 包装成 Word 兼容的 HTML(mso 命名空间)
  const wordHtml = `
    <html xmlns:o='urn:schemas-microsoft-com:office:office'
          xmlns:w='urn:schemas-microsoft-com:office:word'>
      <head><meta charset="utf-8"></head>
      <body>${html}</body>
    </html>
  `;

  // 3. 下载为 .doc 文件
  const blob = new Blob(['\ufeff' + wordHtml], {
    type: 'application/msword'
  });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `${filename}.doc`;
  a.click();
  URL.revokeObjectURL(url);
}

这个技巧的核心是:Word 从 2007 版本开始就支持直接打开 HTML 文件,我们只需要在 HTML 里加上 Word 的命名空间声明即可。不需要依赖 python-docx 或任何后端库。

工具栏设计:「少即是多」

作为一个在线工具而不是笔记软件,编辑器工具栏保持极简:

粗体 | 斜体 | 标题 | 链接 | 图片 | 代码 | 引用 | 列表 | 表格
导出:Markdown | HTML | Word | PDF

功能不在于多,在于用户最常用的那几个按钮触手可及。剩下的交给 Markdown 语法本身 —— Ctrl+B 加粗比点按钮快得多。

Markdown编辑器 - 操作页面.png


线上体验

以上 5 个工具都已在线上运行,如果你刚好有文档处理的需求可以试试。无需安装,打开浏览器就能用。

找的方式也很简单:百度搜索「工具派」,搜索结果第一个就是。

这个平台总共集成了 45+ 个在线工具,涵盖 PDF 处理、图片编辑、视频处理、开发工具、效率办公等分类。上面这 5 个文档工具是使用频率比较高的几个。


总结

这篇文章拆解了 5 个在线文档工具的实现方案,核心技术和设计思路汇总:

  1. Word 合并拆分:服务端用 python-docx 处理,关键在段落/表格/图片的逐元素复制
  2. 文件批量重命名:纯前端实现,规则引擎 + 实时预览 + JSZip 打包下载
  3. 文档模板填充:python-docx 变量替换 + Excel 数据源解析 + 线程池并发处理
  4. 文件加密:纯前端 Web Crypto API,AES-256-GCM + PBKDF2 密钥派生,文件完全不上传服务器
  5. Markdown 编辑器:CodeMirror 6 + marked.js + 防抖预览 + localStorage 自动保存 + 多格式导出

如果你也在做类似的工具,或者对某个方案的实现细节有疑问,欢迎评论区交流。有帮助的话点个赞就行 👍