《独立开发者的"富文本粘贴攻坚战":从Word地狱到云端救赎的技术实录》
——上海.NET程序员的破局之路

第一章:当需求文档变成"技术恐怖片"

作为独立开发者,我独自维护着一个基于Vue2 + wangEditor的文档协作平台。某天,产品经理甩来一份需求:
“需要支持从Word/Excel/PPT/PDF直接粘贴/导入内容,图片自动上传至阿里云OSS,且保留原始样式(字体/颜色/表格等)。后端用ASP.NET Core,数据库SQL Server。”

我盯着屏幕陷入沉思:这哪是需求,分明是**“全栈开发者の终极试炼”**。


第二章:前端战场——wangEditor的"粘贴拦截术"

1. 破解Word的"脏HTML"陷阱

Word粘贴的内容自带等Office专属标签,直接渲染会乱码。我的解决方案:

// wangEditor配置增强
const editor = new WangEditor('#editor')
editor.config.pasteFilterStyle = false // 允许基础样式
editor.config.pasteIgnoreImg = false // 不忽略图片

// 自定义粘贴处理
editor.config.customPaste = (editor, html) => {
  // 1. 清理Office冗余标签(正则暴击)
  let cleanHtml = html
    .replace(//g, '') // 移除Office绘图标签
    .replace(/<\/o:p>/g, '
') // 换行符转换
    .replace(//g, '') // 移除注释

  // 2. 提取Base64图片并触发上传
  return extractImagesAndUpload(cleanHtml)
}
2. 图片自动上传:从Base64到Blob的蜕变

Word图片默认是Base64编码,直接传输效率低下。我实现了分步处理:

async function extractImagesAndUpload(html) {
  const parser = new DOMParser()
  const doc = parser.parseFromString(html, 'text/html')
  const images = doc.querySelectorAll('img[src^="data:image"]')
  
  for (let img of images) {
    const base64 = img.src.split(',')[1]
    const blob = await base64ToBlob(base64) // 自定义转换函数
    
    // 调用ASP.NET后端API
    const formData = new FormData()
    formData.append('file', blob, 'word-image.png')
    
    fetch('/api/upload', {
      method: 'POST',
      body: formData
    })
    .then(res => res.json())
    .then(data => {
      img.src = data.url // 替换为OSS URL
    })
  }
  
  return doc.body.innerHTML
}

效果:用户粘贴Word后,图片自动上传至OSS,编辑器内显示可访问的URL,且保留了原始字体、颜色等样式。


第三章:后端战场——ASP.NET Core的"对象存储攻防战"

1. 文件接收与OSS上传

创建专门的FileUploadController处理图片上传:

[ApiController]
[Route("api/[controller]")]
public class UploadController : ControllerBase
{
    private readonly IOSSClient _ossClient; // 阿里云OSS封装接口

    [HttpPost]
    public async Task Upload(IFormFile file)
    {
        if (file == null || file.Length == 0) return BadRequest();

        // 生成唯一文件名
        var ext = Path.GetExtension(file.FileName);
        var fileName = $"{Guid.NewGuid()}{ext}";

        // 上传至OSS(使用阿里云SDK)
        using var stream = file.OpenReadStream();
        await _ossClient.PutObjectAsync("your-bucket", $"uploads/{fileName}", stream);

        // 返回可访问URL
        var url = $"https://your-bucket.oss-cn-shanghai.aliyuncs.com/uploads/{fileName}";
        return Ok(new { url });
    }
}
2. 文档导入:多格式转换方案

针对不同文档类型采用不同策略:

① Word/Excel/PPT → HTML
使用LibreOffice(通过Docker部署):

public async Task ConvertOfficeToHtml(string filePath, string fileType)
{
    var outputPath = Path.Combine("/tmp", $"{Guid.NewGuid()}.html");
    
    // 调用LibreOffice命令行转换
    var process = new Process
    {
        StartInfo = new ProcessStartInfo
        {
            FileName = "soffice",
            Arguments = $"--headless --convert-to html:{fileType} --outdir /tmp {filePath}",
            RedirectStandardOutput = true,
            UseShellExecute = false
        }
    };
    
    await process.StartAsync();
    await process.WaitForExitAsync();
    
    return File.ReadAllText(outputPath);
}

② PDF → HTML
使用pdf2htmlEX(需单独安装):

public async Task ConvertPdfToHtml(string filePath)
{
    var outputPath = Path.Combine("/tmp", $"{Guid.NewGuid()}.html");
    
    var process = new Process
    {
        StartInfo = new ProcessStartInfo
        {
            FileName = "pdf2htmlEX",
            Arguments = $"--zoom 1.3 {filePath} {outputPath}",
            RedirectStandardOutput = true,
            UseShellExecute = false
        }
    };
    
    await process.StartAsync();
    await process.WaitForExitAsync();
    
    return File.ReadAllText(outputPath);
}

坑点记录

  • LibreOffice转换Excel时,复杂公式可能丢失
  • pdf2htmlEX生成的HTML需要额外CSS重置样式
  • 所有转换工具建议通过Docker部署,避免环境污染

第四章:数据库设计——SQL Server的"元数据管理"

为支持文档历史版本,设计了以下表结构:

CREATE TABLE Documents (
    Id UNIQUEIDENTIFIER PRIMARY KEY,
    Title NVARCHAR(255),
    Content NVARCHAR(MAX), -- 存储最终HTML
    CreatedAt DATETIME DEFAULT GETDATE(),
    UpdatedAt DATETIME DEFAULT GETDATE()
);

CREATE TABLE DocumentImages (
    Id UNIQUEIDENTIFIER PRIMARY KEY,
    DocumentId UNIQUEIDENTIFIER FOREIGN KEY REFERENCES Documents(Id),
    ImageUrl NVARCHAR(512),
    OriginalName NVARCHAR(255),
    Size INT,
    UploadTime DATETIME DEFAULT GETDATE()
);

第五章:同行互助——QQ群里的"技术急救"

在开发过程中,加入QQ群:223813913后发现:

  • @北京-张工:“推荐用Mammoth.js处理Word,比LibreOffice轻量!”
  • @深圳-李姐:“Excel转换后表格线消失?试试OpenXML SDK直接解析.xlsx”
  • @杭州-王总:“阿里云OSS上传建议用预签名URL,避免前端直接传密钥”

群内资源推荐

  • 前端:mammoth.js(Word转HTML)
  • 后端:NPOI(.NET处理Office文件)
  • 工具:Docker(隔离转换环境)

最终成果:从需求到上线的完整闭环

现在系统支持:
Word粘贴:内容+图片自动上传,样式保留
多文档导入:Word/Excel/PPT/PDF一键转换
对象存储:图片存阿里云OSS,支持CDN加速
版本管理:SQL Server记录文档修改历史

技术栈总结

  • 前端:Vue2 + wangEditor(粘贴拦截+图片上传)
  • 后端:ASP.NET Core(文件处理+OSS SDK)
  • 转换工具:LibreOffice + pdf2htmlEX
  • 存储:阿里云OSS + SQL Server

最后呼吁
如果你也在攻克富文本编辑器难题,欢迎加入QQ群:223813913,这里没有"菜鸟",只有"即将成为大神的同行"!

(完)
——上海独立开发者·陈工

复制插件文件

WordPaster插件文件夹
安装jquery

npm install jquery

导入组件

import E from 'wangeditor'
const { $, BtnMenu, DropListMenu, PanelMenu, DropList, Panel, Tooltip } = E
import {WordPaster} from '../../static/WordPaster/js/w'
import {zyCapture} from '../../static/zyCapture/z'
import {zyOffice} from '../../static/zyOffice/js/o'

初始化组件




//zyCapture Button
class zyCaptureBtn extends BtnMenu {
    constructor(editor) {
        const $elem = E.$(
            `<div class="w-e-menu" data-title="截屏">
                <img src="../../static/zyCapture/z.png"/>
            </div>`
        )
        super($elem, editor)
    }
    clickHandler() {
        window.zyCapture.setEditor(this.editor).Capture();
    }
    tryChangeActive() {this.active()}
}
//zyOffice Button
class importWordBtn extends BtnMenu {
    constructor(editor) {
        const $elem = E.$(
            `<div class="w-e-menu" data-title="导入Word文档(docx)">
                <img src="../../static/zyOffice/css/w.png"/>
            </div>`
        )
        super($elem, editor)
    }
    clickHandler() {
        window.zyOffice.SetEditor(this.editor).api.openDoc();
    }
    tryChangeActive() {this.active()}
}
//zyOffice Button
class exportWordBtn extends BtnMenu {
    constructor(editor) {
        const $elem = E.$(
            `<div class="w-e-menu" data-title="导出Word文档(docx)">
                <img src="../../static/zyOffice/css/exword.png"/>
            </div>`
        )
        super($elem, editor)
    }
    clickHandler() {
        window.zyOffice.SetEditor(this.editor).api.exportWord();
    }
    tryChangeActive() {this.active()}
}
//zyOffice Button
class importPdfBtn extends BtnMenu {
    constructor(editor) {
        const $elem = E.$(
            `<div class="w-e-menu" data-title="导入PDF文档">
                <img src="../../static/zyOffice/css/pdf.png"/>
            </div>`
        )
        super($elem, editor)
    }
    clickHandler() {
        window.zyOffice.SetEditor(this.editor).api.openPdf();
    }
    tryChangeActive() {this.active()}
}

//WordPaster Button
class WordPasterBtn extends BtnMenu {
    constructor(editor) {
        const $elem = E.$(
            `<div class="w-e-menu" data-title="Word一键粘贴">
                <img src="../../static/WordPaster/w.png"/>
            </div>`
        )
        super($elem, editor)
    }
    clickHandler() {
        WordPaster.getInstance().SetEditor(this.editor).Paste();
    }
    tryChangeActive() {this.active()}
}
//wordImport Button
class WordImportBtn extends BtnMenu {
    constructor(editor) {
        const $elem = E.$(
            `<div class="w-e-menu" data-title="导入Word文档">
                <img src="../../static/WordPaster/css/doc.png"/>
            </div>`
        )
        super($elem, editor)
    }
    clickHandler() {
        WordPaster.getInstance().SetEditor(this.editor).importWord();
    }
    tryChangeActive() {this.active()}
}
//excelImport Button
class ExcelImportBtn extends BtnMenu {
    constructor(editor) {
        const $elem = E.$(
            `<div class="w-e-menu" data-title="导入Excel文档">
                <img src="../../static/WordPaster/css/xls.png"/>
            </div>`
        )
        super($elem, editor)
    }
    clickHandler() {
        WordPaster.getInstance().SetEditor(this.editor).importExcel();
    }
    tryChangeActive() {this.active()}
}
//ppt paster Button
class PPTImportBtn extends BtnMenu {
    constructor(editor) {
        const $elem = E.$(
            `<div class="w-e-menu" data-title="导入PPT文档">
                <img src="../../static/WordPaster/css/ppt1.png"/>
            </div>`
        )
        super($elem, editor)
    }
    clickHandler() {
        WordPaster.getInstance().SetEditor(this.editor).importPPT();
    }
    tryChangeActive() {this.active()}
}
//pdf paster Button
class PDFImportBtn extends BtnMenu {
    constructor(editor) {
        const $elem = E.$(
            `<div class="w-e-menu" data-title="导入PDF文档">
                <img src="../../static/WordPaster/css/pdf.png"/>
            </div>`
        )
        super($elem, editor)
    }
    clickHandler() {
        WordPaster.getInstance().SetEditor(this.editor);
        WordPaster.getInstance().ImportPDF();
    }
    tryChangeActive() {this.active()}
}
//importWordToImg Button
class ImportWordToImgBtn extends BtnMenu {
    constructor(editor) {
        const $elem = E.$(
            `<div class="w-e-menu" data-title="Word转图片">
                <img src="../../static/WordPaster/word1.png"/>
            </div>`
        )
        super($elem, editor)
    }
    clickHandler() {
        WordPaster.getInstance().SetEditor(this.editor).importWordToImg();
    }
    tryChangeActive() {this.active()}
}
//network paster Button
class NetImportBtn extends BtnMenu {
    constructor(editor) {
        const $elem = E.$(
            `<div class="w-e-menu" data-title="网络图片一键上传">
                <img src="../../static/WordPaster/net.png"/>
            </div>`
        )
        super($elem, editor)
    }
    clickHandler() {
        WordPaster.getInstance().SetEditor(this.editor);
        WordPaster.getInstance().UploadNetImg();
    }
    tryChangeActive() {this.active()}
}

export default {
  name: 'HelloWorld',
  data () {
    return {
      msg: 'Welcome to Your Vue.js App'
    }
  },
  mounted(){
    var editor = new E('#editor');
    WordPaster.getInstance({
        //上传接口:http://www.ncmem.com/doc/view.aspx?id=d88b60a2b0204af1ba62fa66288203ed
        PostUrl: "http://localhost:8891/upload.aspx",
        License2:"",
        //为图片地址增加域名: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: ''
    });

    zyCapture.getInstance({
        config: {
            PostUrl: "http://localhost:8891/upload.aspx",
            License2: '',
            FileFieldName: "file",
            Fields: { uname: "test" },
            ImageUrl: 'http://localhost:8891{url}'
        }
    })

    // zyoffice,
    // 使用前请在服务端部署zyoffice,
    // http://www.ncmem.com/doc/view.aspx?id=82170058de824b5c86e2e666e5be319c
    zyOffice.getInstance({
        word: 'http://localhost:13710/zyoffice/word/convert',
        wordExport: 'http://localhost:13710/zyoffice/word/export',
        pdf: 'http://localhost:13710/zyoffice/pdf/upload'
    })

    // 注册菜单
    E.registerMenu("zyCaptureBtn", zyCaptureBtn)
    E.registerMenu("WordPasterBtn", WordPasterBtn)
    E.registerMenu("ImportWordToImgBtn", ImportWordToImgBtn)
    E.registerMenu("NetImportBtn", NetImportBtn)
    E.registerMenu("WordImportBtn", WordImportBtn)
    E.registerMenu("ExcelImportBtn", ExcelImportBtn)
    E.registerMenu("PPTImportBtn", PPTImportBtn)
    E.registerMenu("PDFImportBtn", PDFImportBtn)
    E.registerMenu("importWordBtn", importWordBtn)
    E.registerMenu("exportWordBtn", exportWordBtn)
    E.registerMenu("importPdfBtn", importPdfBtn)


    //挂载粘贴事件
    editor.txt.eventHooks.pasteEvents.length=0;
    editor.txt.eventHooks.pasteEvents.push(function(){
      WordPaster.getInstance().SetEditor(editor).Paste();
      e.preventDefault();
    });
    editor.create();

    var edt2 = new E('#editor2');
    //挂载粘贴事件
    edt2.txt.eventHooks.pasteEvents.length=0;
    edt2.txt.eventHooks.pasteEvents.push(function(){
      WordPaster.getInstance().SetEditor(edt2).Paste();
      e.preventDefault();
      return;
    });
    edt2.create();
  }
}




h1, h2 {
  font-weight: normal;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}

测试前请配置图片上传接口并测试成功
接口测试
接口返回JSON格式参考

为编辑器添加按钮

  components: { Editor, Toolbar },
  data () {
    return {
      editor: null,
      html: 'dd',
      toolbarConfig: {
        insertKeys: {
          index: 0,
          keys: ['zycapture', 'wordpaster', 'pptimport', 'pdfimport', 'netimg', 'importword', 'exportword', 'importpdf']
        }
      },
      editorConfig: {
        placeholder: ''
      },
      mode: 'default' // or 'simple'
    }
  },

整合效果

wangEditor4整合效果

导入Word文档,支持doc,docx

粘贴Word和图片

导入Excel文档,支持xls,xlsx

粘贴Word和图片

粘贴Word

一键粘贴Word内容,自动上传Word中的图片,保留文字样式。
粘贴Word和图片

Word转图片

一键导入Word文件,并将Word文件转换成图片上传到服务器中。
导入Word转图片

导入PDF

一键导入PDF文件,并将PDF转换成图片上传到服务器中。
导入PDF转图片

导入PPT

一键导入PPT文件,并将PPT转换成图片上传到服务器中。
导入PPT转图片

上传网络图片

一键自动上传网络图片,自动下载远程服务器图片,自动上传远程服务器图片
自动上传网络图片

下载示例

点击下载完整示例

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐