MIME类型检测说明.md 7.4 KB

MIME 类型检测说明

问题

后端下载接口返回的 MIME 类型是 application/octet-stream,但 Clipboard API 只支持图片类型:

Failed to execute 'write' on 'Clipboard': 
Type application/octet-stream not supported on write.

原因

后端代码设置了通用的二进制流类型:

response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE + "; charset=UTF-8");

Clipboard API 只接受以下图片 MIME 类型:

  • image/png
  • image/jpeg
  • image/gif
  • image/bmp
  • image/webp
  • image/svg+xml

解决方案

在前端通过读取文件头(Magic Number)来检测真实的图片类型,然后创建正确 MIME 类型的 Blob。

实现代码

// 1. 下载图片
const blob = await downloadSignature(ossId);

// 2. 检测图片真实类型
let imageBlob = blob;
let mimeType = blob.type;

// 如果是 application/octet-stream,需要检测真实的图片类型
if (mimeType === 'application/octet-stream' || !mimeType.startsWith('image/')) {
  // 读取文件头
  const arrayBuffer = await blob.arrayBuffer();
  const uint8Array = new Uint8Array(arrayBuffer);
  
  // 检测文件头(Magic Number)
  if (uint8Array[0] === 0xFF && uint8Array[1] === 0xD8 && uint8Array[2] === 0xFF) {
    mimeType = 'image/jpeg';
  } else if (uint8Array[0] === 0x89 && uint8Array[1] === 0x50 && uint8Array[2] === 0x4E && uint8Array[3] === 0x47) {
    mimeType = 'image/png';
  } else if (uint8Array[0] === 0x47 && uint8Array[1] === 0x49 && uint8Array[2] === 0x46) {
    mimeType = 'image/gif';
  } else if (uint8Array[0] === 0x42 && uint8Array[1] === 0x4D) {
    mimeType = 'image/bmp';
  } else if (uint8Array[0] === 0x52 && uint8Array[1] === 0x49 && uint8Array[2] === 0x46 && uint8Array[3] === 0x46) {
    mimeType = 'image/webp';
  } else {
    // 默认使用 PNG
    mimeType = 'image/png';
  }
  
  // 创建新的 Blob,使用正确的 MIME 类型
  imageBlob = new Blob([arrayBuffer], { type: mimeType });
}

// 3. 复制到剪贴板
await navigator.clipboard.write([
  new ClipboardItem({
    [imageBlob.type]: imageBlob
  })
]);

文件头(Magic Number)对照表

格式 文件头(十六进制) 说明
JPEG FF D8 FF JPEG 图片
PNG 89 50 4E 47 PNG 图片(\x89PNG)
GIF 47 49 46 GIF 图片(GIF)
BMP 42 4D BMP 图片(BM)
WebP 52 49 46 46 WebP 图片(RIFF)
SVG 3C 73 76 67 SVG 图片(<svg)

工作流程

下载图片 Blob
    ↓
检查 MIME 类型
    ↓
是 application/octet-stream?
    ├─ 是 → 读取文件头
    │        ↓
    │      检测图片类型
    │        ↓
    │      创建新 Blob(正确的 MIME 类型)
    │        ↓
    └─ 否 → 直接使用原 Blob
    ↓
复制到剪贴板

示例

JPEG 文件头检测

const uint8Array = new Uint8Array(arrayBuffer);

// JPEG 文件头:FF D8 FF
if (uint8Array[0] === 0xFF && 
    uint8Array[1] === 0xD8 && 
    uint8Array[2] === 0xFF) {
  mimeType = 'image/jpeg';
}

PNG 文件头检测

// PNG 文件头:89 50 4E 47(\x89PNG)
if (uint8Array[0] === 0x89 && 
    uint8Array[1] === 0x50 && 
    uint8Array[2] === 0x4E && 
    uint8Array[3] === 0x47) {
  mimeType = 'image/png';
}

GIF 文件头检测

// GIF 文件头:47 49 46(GIF)
if (uint8Array[0] === 0x47 && 
    uint8Array[1] === 0x49 && 
    uint8Array[2] === 0x46) {
  mimeType = 'image/gif';
}

调试日志

console.log('[签名] 图片下载成功,大小:', blob.size, '原始类型:', blob.type);
console.log('[签名] 检测到非图片 MIME 类型,尝试转换');
console.log('[签名] 检测到的图片类型:', mimeType);
console.log('[签名] 最终 MIME 类型:', imageBlob.type);

输出示例

[签名] 图片下载成功,大小: 12345 原始类型: application/octet-stream
[签名] 检测到非图片 MIME 类型,尝试转换
[签名] 检测到的图片类型: image/png
[签名] 最终 MIME 类型: image/png

优化建议

前端方案(当前实现)

✅ 优点:

  • 无需修改后端
  • 兼容性好
  • 自动检测图片类型

⚠️ 缺点:

  • 需要读取整个文件到内存
  • 增加前端处理逻辑

后端方案(推荐)

修改后端代码,返回正确的 MIME 类型:

@Override
public void download(Long ossId, HttpServletResponse response) throws IOException {
    SysOssVo sysOss = SpringUtils.getAopProxy(this).getById(ossId);
    if (ObjectUtil.isNull(sysOss)) {
        throw new ServiceException("文件数据不存在!");
    }
    
    // 根据文件扩展名设置正确的 Content-Type
    String fileName = sysOss.getOriginalName();
    String contentType = getContentTypeByFileName(fileName);
    
    FileUtils.setAttachmentResponseHeader(response, fileName);
    response.setContentType(contentType); // ← 使用正确的 MIME 类型
    
    OssClient storage = OssFactory.instance(sysOss.getService());
    storage.download(sysOss.getFileName(), response.getOutputStream(), response::setContentLengthLong);
}

private String getContentTypeByFileName(String fileName) {
    String ext = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
    switch (ext) {
        case "jpg":
        case "jpeg":
            return "image/jpeg";
        case "png":
            return "image/png";
        case "gif":
            return "image/gif";
        case "bmp":
            return "image/bmp";
        case "webp":
            return "image/webp";
        case "svg":
            return "image/svg+xml";
        default:
            return MediaType.APPLICATION_OCTET_STREAM_VALUE;
    }
}

测试

测试用例 1:JPEG 图片

// 文件头:FF D8 FF
// 预期:检测为 image/jpeg

测试用例 2:PNG 图片

// 文件头:89 50 4E 47
// 预期:检测为 image/png

测试用例 3:GIF 图片

// 文件头:47 49 46
// 预期:检测为 image/gif

测试用例 4:未知格式

// 文件头:不匹配任何已知格式
// 预期:默认使用 image/png

浏览器兼容性

浏览器 ArrayBuffer Uint8Array Clipboard API 支持
Chrome 90+
Edge 90+
Firefox 88+
Safari 14+ ⚠️ ⚠️

性能考虑

内存使用

// 读取整个文件到内存
const arrayBuffer = await blob.arrayBuffer(); // ← 占用内存

// 只读取前几个字节(优化方案)
const slice = blob.slice(0, 8); // 只读取前 8 字节
const arrayBuffer = await slice.arrayBuffer();

优化版本

// 只读取文件头(前 8 字节)
const headerBlob = blob.slice(0, 8);
const arrayBuffer = await headerBlob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);

// 检测类型...

// 使用原始 Blob 创建新 Blob
imageBlob = new Blob([blob], { type: mimeType });

总结

问题

  • 后端返回 application/octet-stream
  • Clipboard API 不支持此类型

解决

  • 读取文件头检测真实图片类型
  • 创建正确 MIME 类型的 Blob
  • 成功复制到剪贴板

优势

  • ✅ 无需修改后端
  • ✅ 自动检测图片类型
  • ✅ 兼容所有图片格式
  • ✅ 完善的错误处理

现在用户可以成功复制签名图片到剪贴板了!