农业物联网平台如何改造TinyMCE4实现监测报告截图图片的自动排版粘贴上传?
成果首次在金融行业实现高保真文档导入通过等保三级安全认证客户满意度达9.2/10教训开源方案二次开发成本可能高于自主开发金融行业对样式精确度的要求远超预期大文件处理必须从架构层面设计后续计划2023年Q4支持LaTeX公式导入接入AI智能识别,自动调整异常样式建立金融文档样式标准库当看到系统成功处理某券商的300页IPO文件时,测试总监感叹:“这比专业文档转换工具还稳定”。这一刻,所有熬夜调试的疲
金融系统文档导入功能开发实录:从技术焦虑到突破的全过程
2023年8月14日 周一 需求确认
作为公司前端开发组的核心成员,我接到了产品总监的紧急需求:在现有金融业务系统中新增Word/PDF导入功能,要求完整保留文档中的表格样式、公司LOGo和金融图表。当前系统架构为:
- 前端:Vue2-CLI + TinyMCE4(富文本编辑器)
- 后端:SpringBoot 2.7 + MySQL 8.0
- 安全要求:等保三级合规
“这个需求的关键是样式保真度”,我在需求评审会上强调,“特别是那些带条件格式的财务报表和矢量图表”。技术总监敲了敲白板:“给你们三周时间,必须通过金融行业的压力测试——客户要导入的文档可能包含200页的招股说明书”。
8月15日-17日 开源方案探索
Day1:TinyMCE生态调研
- 测试
tinymce-powerpaste
插件:虽然能保留样式,但599美元/年的授权费超出预算 - 发现
mammoth.js
开源库:能提取Word文档结构,但无法处理复杂表格和图片 - 尝试
docx-preview
:生成的HTML与TinyMCE不兼容,嵌套表格全部错位
Day2:PDF处理困境
- 用
pdf.js
渲染的文档在编辑器中出现严重偏移 pdf2htmlEX
转换后产生大量冗余``标签,导致编辑器卡顿- 测试
Apache PDFBox
:后端解析速度太慢,单页PDF处理耗时3秒
Day3:混合方案尝试
- 前端用
mammoth.js
提取文本+图片元数据 - 后端用
Apache POI
处理复杂样式 - 发现图片处理存在跨域问题,上传七牛云时token失效
8月18日 技术方案突破
凌晨两点,我在重看TinyMCE文档时突然意识到:或许可以分层处理文档内容!
最终架构设计:
-
前端预处理层
- 使用Web Worker解析文档(避免阻塞UI)
- 图片分片上传至对象存储(阿里云OSS)
- 生成样式标记(如
[table:finance]
)
-
后端处理层
- SpringBoot接收标记HTML
- 用Jsoup清理XSS漏洞
- 金融样式增强引擎(自定义CSS映射)
-
编辑器适配层
- 扩展TinyMCE的
paste
插件 - 实现特殊标记转换(如
[table:finance]
→ 预定义样式表)
- 扩展TinyMCE的
8月21日-25日 核心代码实现
前端实现(Vue组件):
// DocxImporter.vue
export default {
methods: {
async handleFile(file) {
// 1. 文件校验
if (!file.name.match(/\.(docx|pdf)$/)) {
this.$message.error('仅支持docx/pdf格式');
return;
}
// 2. 启动Web Worker解析
const worker = new Worker('./docx-parser.worker.js');
worker.postMessage({ file });
// 3. 处理进度反馈
worker.onmessage = (e) => {
if (e.data.type === 'progress') {
this.progress = e.data.value;
} else if (e.data.type === 'result') {
this.insertToEditor(e.data.html);
}
};
},
insertToEditor(html) {
// 金融样式增强
const enhancedHtml = html
.replace(/ {
const { file } = e.data;
// 1. 读取文件ArrayBuffer
const buffer = await file.arrayBuffer();
// 2. 使用mammoth提取内容(基础解析)
const result = await mammoth.extractRawText({ arrayBuffer: buffer });
// 3. 自定义图片处理器
const images = [];
result.messages.forEach(msg => {
if (msg.type === 'warning' && msg.message.includes('image')) {
const imageId = msg.message.match(/image-(\d+)/)[1];
images.push(extractImage(buffer, imageId));
}
});
// 4. 上传图片到OSS
const imageUrls = await Promise.all(
images.map(img => uploadToOSS(img))
);
// 5. 替换HTML中的图片标记
let html = result.value;
imageUrls.forEach((url, idx) => {
html = html.replace(`[image:${idx}]`, ``);
});
self.postMessage({
type: 'result',
html
});
};
后端安全处理(SpringBoot):
@Service
public class DocumentSanitizer {
// 金融样式白名单
private static final Set ALLOWED_CLASSES =
Set.of("finance-table", "finance-title", "chart-container");
public String sanitize(String html) {
// 1. 使用Jsoup清理危险标签
Document doc = Jsoup.parse(html);
doc.select("script, iframe, object, embed, form, input").remove();
// 2. 过滤非法样式类
doc.select("*").forEach(element -> {
String classAttr = element.attr("class");
if (!classAttr.isEmpty()) {
String[] classes = classAttr.split("\\s+");
List validClasses = Arrays.stream(classes)
.filter(ALLOWED_CLASSES::contains)
.collect(Collectors.toList());
element.attr("class", String.join(" ", validClasses));
}
});
// 3. 添加金融行业必备属性
doc.select("table").attr("border", "1")
.attr("cellspacing", "0")
.attr("cellpadding", "5");
return doc.html();
}
}
8月26日-28日 性能优化
问题1:大文件上传超时
- 解决方案:实现分片上传+断点续传
// 前端分片上传
async function uploadInChunks(file, chunkSize = 5 * 1024 * 1024) {
const chunks = Math.ceil(file.size / chunkSize);
const uploadPromises = [];
for (let i = 0; i < chunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
uploadPromises.push(
axios.post('/api/upload/chunk', {
file: chunk,
chunkIndex: i,
totalChunks: chunks,
fileHash: await calculateFileHash(file) // 前端计算文件指纹
})
);
}
await Promise.all(uploadPromises);
return await mergeChunks(file.name, chunks);
}
问题2:后端解析内存溢出
- 解决方案:使用流式处理+临时文件
@PostMapping("/import")
public ResponseEntity importDocument(@RequestParam("file") MultipartFile file) {
try {
// 1. 保存到临时文件
Path tempFile = Files.createTempFile("doc-", ".tmp");
file.transferTo(tempFile.toFile());
// 2. 使用流式解析
try (InputStream is = Files.newInputStream(tempFile)) {
String html = documentParser.parse(is); // 流式处理
String sanitized = sanitizer.sanitize(html);
return ResponseEntity.ok(sanitized);
}
} finally {
// 清理临时文件
// ...
}
}
8月29日 金融级安全加固
-
数据校验三重防护
- 前端:文件类型白名单+大小限制(50MB)
- 网关层:Nginx限制上传速率(2MB/s)
- 后端:Magic Number校验文件真实类型
-
XSS防护增强
// 前端二次转义
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
- 审计日志记录
CREATE TABLE document_import_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(32) NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL,
ip_address VARCHAR(45) NOT NULL,
import_result TINYINT NOT NULL COMMENT '0:成功 1:失败 2:部分成功',
error_message TEXT,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
8月30日 验收测试
测试用例1:200页招股说明书
- 导入时间:3分15秒(原需求≤5分钟)
- 样式保留率:92%(复杂图表需手动调整)
- 图片完整率:100%
测试用例2:含宏的Word文档
- 自动拦截并提示:“检测到宏内容,已自动清除”
测试用例3:PDF表单
- 成功提取文本内容,但表单控件转为静态图片
9月1日 项目总结
成果:
- 首次在金融行业实现高保真文档导入
- 通过等保三级安全认证
- 客户满意度达9.2/10
教训:
- 开源方案二次开发成本可能高于自主开发
- 金融行业对样式精确度的要求远超预期
- 大文件处理必须从架构层面设计
后续计划:
- 2023年Q4支持LaTeX公式导入
- 接入AI智能识别,自动调整异常样式
- 建立金融文档样式标准库
当看到系统成功处理某券商的300页IPO文件时,测试总监感叹:“这比专业文档转换工具还稳定”。这一刻,所有熬夜调试的疲惫都化作了成就感——我们不仅完成了需求,更重新定义了金融行业文档导入的标准。
复制插件
安装jquery
npm install jquery
在组件中引入
// 引入tinymce-vue
import Editor from '@tinymce/tinymce-vue'
import {WordPaster} from '../../static/WordPaster/js/w'
import {zyOffice} from '../../static/zyOffice/js/o'
import {zyCapture} from '../../static/zyCapture/z'
添加工具栏
//添加导入excel工具栏按钮
(function () {
'use strict';
var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
function selectLocalImages(editor) {
WordPaster.getInstance().SetEditor(editor).importExcel()
}
var register$1 = function (editor) {
editor.ui.registry.addButton('excelimport', {
text: '',
tooltip: '导入Excel文档',
onAction: function () {
selectLocalImages(editor)
}
});
editor.ui.registry.addMenuItem('excelimport', {
text: '',
tooltip: '导入Excel文档',
onAction: function () {
selectLocalImages(editor)
}
});
};
var Buttons = { register: register$1 };
function Plugin () {
global.add('excelimport', function (editor) {
Buttons.register(editor);
});
}
Plugin();
}());
//添加word转图片工具栏按钮
(function () {
'use strict';
var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
function selectLocalImages(editor) {
WordPaster.getInstance().SetEditor(editor);
WordPaster.getInstance().importWordToImg()
}
var register$1 = function (editor) {
editor.ui.registry.addButton('importwordtoimg', {
text: '',
tooltip: 'Word转图片',
onAction: function () {
selectLocalImages(editor)
}
});
editor.ui.registry.addMenuItem('importwordtoimg', {
text: '',
tooltip: 'Word转图片',
onAction: function () {
selectLocalImages(editor)
}
});
};
var Buttons = { register: register$1 };
function Plugin () {
global.add('importwordtoimg', function (editor) {
Buttons.register(editor);
});
}
Plugin();
}());
//添加粘贴网络图片工具栏按钮
(function () {
'use strict';
var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
function selectLocalImages(editor) {
WordPaster.getInstance().SetEditor(editor);
WordPaster.getInstance().UploadNetImg()
}
var register$1 = function (editor) {
editor.ui.registry.addButton('netpaster', {
text: '',
tooltip: '网络图片一键上传',
onAction: function () {
selectLocalImages(editor)
}
});
editor.ui.registry.addMenuItem('netpaster', {
text: '',
tooltip: '网络图片一键上传',
onAction: function () {
selectLocalImages(editor)
}
});
};
var Buttons = { register: register$1 };
function Plugin () {
global.add('netpaster', function (editor) {
Buttons.register(editor);
});
}
Plugin();
}());
//添加导入PDF按钮
(function () {
'use strict';
var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
function selectLocalImages(editor) {
WordPaster.getInstance().SetEditor(editor);
WordPaster.getInstance().ImportPDF()
}
var register$1 = function (editor) {
editor.ui.registry.addButton('pdfimport', {
text: '',
tooltip: '导入pdf文档',
onAction: function () {
selectLocalImages(editor)
}
});
editor.ui.registry.addMenuItem('pdfimport', {
text: '',
tooltip: '导入pdf文档',
onAction: function () {
selectLocalImages(editor)
}
});
};
var Buttons = { register: register$1 };
function Plugin () {
global.add('pdfimport', function (editor) {
Buttons.register(editor);
});
}
Plugin();
}());
//添加导入PPT按钮
(function () {
'use strict';
var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
function selectLocalImages(editor) {
WordPaster.getInstance().SetEditor(editor);
WordPaster.getInstance().importPPT()
}
var register$1 = function (editor) {
editor.ui.registry.addButton('pptimport', {
text: '',
tooltip: '导入PowerPoint文档',
onAction: function () {
selectLocalImages(editor)
}
});
editor.ui.registry.addMenuItem('pptimport', {
text: '',
tooltip: '导入PowerPoint文档',
onAction: function () {
selectLocalImages(editor)
}
});
};
var Buttons = { register: register$1 };
function Plugin () {
global.add('pptimport', function (editor) {
Buttons.register(editor);
});
}
Plugin();
}());
//添加导入WORD按钮
(function () {
'use strict';
var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
function selectLocalImages(editor) {
WordPaster.getInstance().SetEditor(editor).importWord()
}
var register$1 = function (editor) {
editor.ui.registry.addButton('wordimport', {
text: '',
tooltip: '导入Word文档',
onAction: function () {
selectLocalImages(editor)
}
});
editor.ui.registry.addMenuItem('wordimport', {
text: '',
tooltip: '导入Word文档',
onAction: function () {
selectLocalImages(editor)
}
});
};
var Buttons = { register: register$1 };
function Plugin () {
global.add('wordimport', function (editor) {
Buttons.register(editor);
});
}
Plugin();
}());
//添加WORD粘贴按钮
(function () {
'use strict';
var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
var ico = "http://localhost:8080/static/WordPaster/plugin/word.png"
function selectLocalImages(editor) {
WordPaster.getInstance().SetEditor(editor).PasteManual()
}
var register$1 = function (editor) {
editor.ui.registry.addButton('wordpaster', {
text: '',
tooltip: 'Word一键粘贴',
onAction: function () {
selectLocalImages(editor)
}
});
editor.ui.registry.addMenuItem('wordpaster', {
text: '',
tooltip: 'Word一键粘贴',
onAction: function () {
selectLocalImages(editor)
}
});
};
var Buttons = { register: register$1 };
function Plugin () {
global.add('wordpaster', function (editor) {
Buttons.register(editor);
});
}
Plugin();
}());
添加插件
// 插件
plugins: {
type: [String, Array],
// default: 'advlist anchor autolink autosave code codesample colorpicker colorpicker contextmenu directionality emoticons fullscreen hr image imagetools importcss insertdatetime link lists media nonbreaking noneditable pagebreak paste preview print save searchreplace spellchecker tabfocus table template textcolor textpattern visualblocks visualchars'
default: 'autoresize code autolink autosave image imagetools paste preview table powertables'
},
初始化组件
// 初始化
WordPaster.getInstance({
// 上传接口:http://www.ncmem.com/doc/view.aspx?id=d88b60a2b0204af1ba62fa66288203ed
PostUrl: 'http://localhost:8891/upload.aspx',
// 为图片地址增加域名:http://www.ncmem.com/doc/view.aspx?id=704cd302ebd346b486adf39cf4553936
ImageUrl: 'http://localhost:8891{url}',
// 设置文件字段名称:http://www.ncmem.com/doc/view.aspx?id=c3ad06c2ae31454cb418ceb2b8da7c45
FileFieldName: 'file',
// 提取图片地址:http://www.ncmem.com/doc/view.aspx?id=07e3f323d22d4571ad213441ab8530d1
ImageMatch: ''
})
在页面中引入组件
功能演示
编辑器
在编辑器中增加功能按钮
导入Word文档,支持doc,docx
导入Excel文档,支持xls,xlsx
粘贴Word
一键粘贴Word内容,自动上传Word中的图片,保留文字样式。
Word转图片
一键导入Word文件,并将Word文件转换成图片上传到服务器中。
导入PDF
一键导入PDF文件,并将PDF转换成图片上传到服务器中。
导入PPT
一键导入PPT文件,并将PPT转换成图片上传到服务器中。
上传网络图片
一键自动上传网络图片。
下载示例
更多推荐
所有评论(0)