每天被 Word 合并拆分、文件批量重命名、模板填充这些重复操作消耗的时间,算下来一年可能有几十个小时。这篇文章从技术实现的角度,拆解这 5 个在线文档工具的底层方案,包含 python-docx、Web Crypto API、CodeMirror 6 的实际应用。
写在前面
工作中经常需要处理各种文档,以下场景应该不陌生:
- 目录里有 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 格式"的提示,能省掉大量用户困惑。
工具二:文件批量重命名
使用场景:照片按日期重命名、代码文件统一加前缀、清理下载目录里的乱码文件名。
这个工具是 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);
}
批量重命名看起来简单,但要做好用户体验,预览和冲突检测这两个环节一个都不能省。
工具三:文档模板填充
使用场景:批量生成证书、合同、邀请函、工牌、成绩单等带变量的文档。
这个功能在企业场景里需求量极大。想象一下:人事要给 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
工具四:文件加密
使用场景:敏感文档分享前的加密保护、合同文件的安全传输、个人隐私文件的本地加密。
文件加密这个需求说大不大,说小不小。偶尔需要给一份文件加个密码,专门装一个加密软件太麻烦,不加密直接发又不够安全。
技术选型:纯前端加密为什么可行?
这个工具的特别之处在于 —— 加密和解密都可以在浏览器端完成,文件不需要上传到服务器。这对于隐私敏感的场景是绝对的加分项。
核心依赖浏览器原生提供的 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-GCM | AES-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';
}
工具五: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 加粗比点按钮快得多。
线上体验
以上 5 个工具都已在线上运行,如果你刚好有文档处理的需求可以试试。无需安装,打开浏览器就能用。
找的方式也很简单:百度搜索「工具派」,搜索结果第一个就是。

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

总结
这篇文章拆解了 5 个在线文档工具的实现方案,核心技术和设计思路汇总:
- Word 合并拆分:服务端用 python-docx 处理,关键在段落/表格/图片的逐元素复制
- 文件批量重命名:纯前端实现,规则引擎 + 实时预览 + JSZip 打包下载
- 文档模板填充:python-docx 变量替换 + Excel 数据源解析 + 线程池并发处理
- 文件加密:纯前端 Web Crypto API,AES-256-GCM + PBKDF2 密钥派生,文件完全不上传服务器
- Markdown 编辑器:CodeMirror 6 + marked.js + 防抖预览 + localStorage 自动保存 + 多格式导出
如果你也在做类似的工具,或者对某个方案的实现细节有疑问,欢迎评论区交流。有帮助的话点个赞就行 👍