后端下载接口返回的 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/pngimage/jpegimage/gifimage/bmpimage/webpimage/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
})
]);
| 格式 | 文件头(十六进制) | 说明 |
|---|---|---|
| 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
↓
复制到剪贴板
const uint8Array = new Uint8Array(arrayBuffer);
// JPEG 文件头:FF D8 FF
if (uint8Array[0] === 0xFF &&
uint8Array[1] === 0xD8 &&
uint8Array[2] === 0xFF) {
mimeType = 'image/jpeg';
}
// PNG 文件头:89 50 4E 47(\x89PNG)
if (uint8Array[0] === 0x89 &&
uint8Array[1] === 0x50 &&
uint8Array[2] === 0x4E &&
uint8Array[3] === 0x47) {
mimeType = 'image/png';
}
// 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;
}
}
// 文件头:FF D8 FF
// 预期:检测为 image/jpeg
// 文件头:89 50 4E 47
// 预期:检测为 image/png
// 文件头:47 49 46
// 预期:检测为 image/gif
// 文件头:不匹配任何已知格式
// 预期:默认使用 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现在用户可以成功复制签名图片到剪贴板了!