PAYATHON 2026

Quill JS 使用 base64 内联图片时如何保留原始文件名?

技术老齐

读取文件时,可以从 File.name 取得文件名,将它写入图片的 data-filename 属性,再随 HTML 正文发送给 Python 后端。

需要改动两处:接管工具栏的图片按钮,取得用户选择的 File;扩展 Quill 的图片格式,让编辑器识别并保留文件名。仅在 DOM 上调用 setAttribute(),无法保证文件名在保存 Delta、重新加载内容后仍然保留。

为什么 base64 中没有文件名?

FileReader.readAsDataURL(file) 生成的内容通常如下:

data:image/png;base64,iVBORw0KGgo...

其中只有媒体类型和文件数据,没有原始文件名。因此,选择文件时需要单独保存 file.name。后端如果只收到 Data URL,就无法可靠地还原原始文件名。

1. 扩展图片格式,保存 data-filename

在创建 Quill 实例之前注册以下格式。图片仍使用原有的字符串值,文件名作为附加格式保存,image 的值结构保持不变。

import Quill from 'quill';

const BaseImage = Quill.import('formats/image');
const Delta = Quill.import('delta');

class ImageWithFilename extends BaseImage {
  static formats(domNode) {
    const formats = { ...super.formats(domNode) };
    const filename = domNode.getAttribute('data-filename');

    if (filename !== null) {
      formats.filename = filename;
    }

    return formats;
  }

  format(name, value) {
    if (name === 'filename') {
      if (typeof value === 'string' && value.length > 0) {
        this.domNode.setAttribute('data-filename', value);
      } else {
        this.domNode.removeAttribute('data-filename');
      }

      return;
    }

    super.format(name, value);
  }
}

Quill.register('formats/image', ImageWithFilename, true);

filename 是 Quill 内部的格式名,对应 HTML 中的 data-filename 属性。formats() 负责读取,format() 负责写入和删除。

注册代码应放在模块作用域,使用与创建编辑器时相同的 Quill 导入。如果项目已有自定义图片格式,需要将文件名逻辑合并进去,避免覆盖已有功能。

2. 接管工具栏的图片按钮

自定义处理器会创建文件选择框,并在读取图片时一并保存 file.name:

function handleImage() {
  const quill = this.quill;
  const range = quill.getSelection(true);
  const savedIndex = range
    ? range.index
    : quill.getLength() - 1;

  const input = document.createElement('input');
  input.type = 'file';
  input.accept = 'image/*';

  input.onchange = () => {
    const file = input.files?.[0];

    if (!file) return;

    // 这里只是前端初步检查,后端仍需验证文件内容。
    if (!file.type.startsWith('image/')) {
      console.error('请选择图片文件');
      return;
    }

    const reader = new FileReader();

    reader.onload = () => {
      if (typeof reader.result !== 'string') return;

      // 读取期间正文可能发生变化,避免索引超出范围。
      const index = Math.min(
        savedIndex,
        quill.getLength() - 1
      );

      quill.updateContents(
        new Delta()
          .retain(index)
          .insert(
            { image: reader.result },
            { filename: file.name }
          ),
        'user'
      );

      quill.setSelection(index + 1, 0, 'silent');
    };

    reader.onerror = () => {
      console.error('图片读取失败', reader.error);
    };

    reader.readAsDataURL(file);
  };

  input.click();
}

处理器需要使用普通函数,因为 Quill 会通过 this.quill 提供编辑器实例。

将原配置中的 modules.toolbar 数组替换为下面的对象,保留其余模块配置:

toolbar: {
  container: [
    [{ header: [1, 2, false] }],
    ['bold', 'italic', 'underline', 'strike', 'blockquote'],
    [{ list: 'ordered' }, { list: 'bullet' }],
    [{ align: [] }],
    ['link', 'image'],
    ['clean'],
    ['table-better'],
  ],
  handlers: {
    image: handleImage,
  },
},

插入后的图片 HTML 类似:

<img
  src="data:image/png;base64,..."
  data-filename="产品截图.png"
>

对应的 Delta 会包含文件名:

{
  insert: {
    image: 'data:image/png;base64,...'
  },
  attributes: {
    filename: '产品截图.png'
  }
}

这是单个 Delta operation。图片和文件名通过同一次 updateContents() 写入,后续可以一起保存。

3. 将 HTML 发送给后端

提交时读取编辑器内容:

const html = quillInstance.current.root.innerHTML;

// 将 html 放入现有的请求正文中。
const payload = { html };

检查实际发送的请求数据,确认其中包含 data-filename。如果发送前还有 HTML 清洗或转换步骤,也需要保留这个属性。

Python 后端可以用 HTML 解析器提取文件名和图片数据。下面使用 Beautiful Soup,运行环境需要先安装 beautifulsoup4:

import base64
from bs4 import BeautifulSoup


def extract_inline_images(html):
    soup = BeautifulSoup(html, "html.parser")
    images = []

    allowed_types = {
        "image/png",
        "image/jpeg",
        "image/gif",
        "image/webp",
    }

    for img in soup.find_all("img"):
        src = img.get("src", "")
        header, separator, encoded = src.partition(",")

        if not separator or not header.startswith("data:"):
            continue

        if not header.endswith(";base64"):
            continue

        media_type = header[5:-7].lower()
        if media_type not in allowed_types:
            continue

        filename = img.get("data-filename") or "image"

        # 不把客户端文件名当成文件路径使用。
        filename = filename.replace("\\", "/").rsplit("/", 1)[-1]
        filename = "".join(
            char for char in filename if ord(char) >= 32
        ).strip() or "image"

        # 无效的 base64 会抛出异常,由请求处理层返回错误。
        content = base64.b64decode(encoded, validate=True)

        images.append({
            "filename": filename,
            "content_type": media_type,
            "content": content,
        })

    return images

提取出的 filename 可以传给邮件库的附件命名参数。例如,使用 Python 的 EmailMessage.add_attachment() 添加普通附件:

for image in extract_inline_images(html):
    maintype, subtype = image["content_type"].split("/", 1)

    message.add_attachment(
        image["content"],
        maintype=maintype,
        subtype=subtype,
        filename=image["filename"],
    )

这里假设 message 已创建并设置好正文。如果图片需要显示在邮件正文中,应将它添加为关联的 MIME 图片部分,设置 Content-ID,并将 HTML 中对应的 src 改为 cid:...。data-filename 只传递文件名,不能替代邮件的 MIME 结构。

也可以把图片信息单独提交

如果不想在 HTML 中保存附件元数据,可以给图片分配唯一 ID:

<img data-image-id="image-001" src="data:image/png;base64,...">

再随请求提交:

{
  "html": "<p>图片:</p><img data-image-id=\"image-001\" src=\"data:image/png;base64,...\">",
  "images": [
    {
      "id": "image-001",
      "filename": "产品截图.png"
    }
  ]
}

采用这种方式,也需要让 Quill 保留 data-image-id,或在提交时建立对应关系。关联图片应使用唯一 ID,因为同一份图片数据可能来自不同文件名,文件名本身也可能重复。

使用时需要注意什么?

  • 上面的处理器只接管工具栏图片按钮。粘贴、拖拽或第三方上传模块需要分别处理。只有拿到 File,才能读取其 name,而剪贴板中的图片未必带有原始文件名。
  • 如果配置了 Quill 的 formats 白名单,需要同时允许 image 和新增的 filename。
  • 图片读取是异步操作。示例保存了插入位置,并限制索引范围。如果需要在读取期间持续编辑后仍准确跟随原位置,就要根据期间的 Delta 变化调整索引。
  • 后端应限制请求体和解码后的图片大小,并验证实际文件内容。accept、file.type 和 Data URL 中的媒体类型都不能代替后端校验。
  • Quill 和图片缩放插件的具体版本尚未提供,插件兼容性仍需确认。接入后应检查缩放、撤销和重做功能,并确认保存、重新加载内容后,data-filename 是否仍然保留。

备注:内容仅供参考。