|
|
@@ -1,294 +0,0 @@
|
|
|
-# MIME 类型检测说明
|
|
|
-
|
|
|
-## 问题
|
|
|
-
|
|
|
-后端下载接口返回的 MIME 类型是 `application/octet-stream`,但 Clipboard API 只支持图片类型:
|
|
|
-
|
|
|
-```
|
|
|
-Failed to execute 'write' on 'Clipboard':
|
|
|
-Type application/octet-stream not supported on write.
|
|
|
-```
|
|
|
-
|
|
|
-## 原因
|
|
|
-
|
|
|
-后端代码设置了通用的二进制流类型:
|
|
|
-```java
|
|
|
-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。
|
|
|
-
|
|
|
-### 实现代码
|
|
|
-
|
|
|
-```typescript
|
|
|
-// 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 文件头检测
|
|
|
-
|
|
|
-```typescript
|
|
|
-const uint8Array = new Uint8Array(arrayBuffer);
|
|
|
-
|
|
|
-// JPEG 文件头:FF D8 FF
|
|
|
-if (uint8Array[0] === 0xFF &&
|
|
|
- uint8Array[1] === 0xD8 &&
|
|
|
- uint8Array[2] === 0xFF) {
|
|
|
- mimeType = 'image/jpeg';
|
|
|
-}
|
|
|
-```
|
|
|
-
|
|
|
-### PNG 文件头检测
|
|
|
-
|
|
|
-```typescript
|
|
|
-// PNG 文件头:89 50 4E 47(\x89PNG)
|
|
|
-if (uint8Array[0] === 0x89 &&
|
|
|
- uint8Array[1] === 0x50 &&
|
|
|
- uint8Array[2] === 0x4E &&
|
|
|
- uint8Array[3] === 0x47) {
|
|
|
- mimeType = 'image/png';
|
|
|
-}
|
|
|
-```
|
|
|
-
|
|
|
-### GIF 文件头检测
|
|
|
-
|
|
|
-```typescript
|
|
|
-// GIF 文件头:47 49 46(GIF)
|
|
|
-if (uint8Array[0] === 0x47 &&
|
|
|
- uint8Array[1] === 0x49 &&
|
|
|
- uint8Array[2] === 0x46) {
|
|
|
- mimeType = 'image/gif';
|
|
|
-}
|
|
|
-```
|
|
|
-
|
|
|
-## 调试日志
|
|
|
-
|
|
|
-```typescript
|
|
|
-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 类型:
|
|
|
-
|
|
|
-```java
|
|
|
-@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 图片
|
|
|
-```typescript
|
|
|
-// 文件头:FF D8 FF
|
|
|
-// 预期:检测为 image/jpeg
|
|
|
-```
|
|
|
-
|
|
|
-### 测试用例 2:PNG 图片
|
|
|
-```typescript
|
|
|
-// 文件头:89 50 4E 47
|
|
|
-// 预期:检测为 image/png
|
|
|
-```
|
|
|
-
|
|
|
-### 测试用例 3:GIF 图片
|
|
|
-```typescript
|
|
|
-// 文件头:47 49 46
|
|
|
-// 预期:检测为 image/gif
|
|
|
-```
|
|
|
-
|
|
|
-### 测试用例 4:未知格式
|
|
|
-```typescript
|
|
|
-// 文件头:不匹配任何已知格式
|
|
|
-// 预期:默认使用 image/png
|
|
|
-```
|
|
|
-
|
|
|
-## 浏览器兼容性
|
|
|
-
|
|
|
-| 浏览器 | ArrayBuffer | Uint8Array | Clipboard API | 支持 |
|
|
|
-|--------|-------------|------------|---------------|------|
|
|
|
-| Chrome 90+ | ✅ | ✅ | ✅ | ✅ |
|
|
|
-| Edge 90+ | ✅ | ✅ | ✅ | ✅ |
|
|
|
-| Firefox 88+ | ✅ | ✅ | ✅ | ✅ |
|
|
|
-| Safari 14+ | ✅ | ✅ | ⚠️ | ⚠️ |
|
|
|
-
|
|
|
-## 性能考虑
|
|
|
-
|
|
|
-### 内存使用
|
|
|
-```typescript
|
|
|
-// 读取整个文件到内存
|
|
|
-const arrayBuffer = await blob.arrayBuffer(); // ← 占用内存
|
|
|
-
|
|
|
-// 只读取前几个字节(优化方案)
|
|
|
-const slice = blob.slice(0, 8); // 只读取前 8 字节
|
|
|
-const arrayBuffer = await slice.arrayBuffer();
|
|
|
-```
|
|
|
-
|
|
|
-### 优化版本
|
|
|
-
|
|
|
-```typescript
|
|
|
-// 只读取文件头(前 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
|
|
|
-- 成功复制到剪贴板
|
|
|
-
|
|
|
-### 优势
|
|
|
-- ✅ 无需修改后端
|
|
|
-- ✅ 自动检测图片类型
|
|
|
-- ✅ 兼容所有图片格式
|
|
|
-- ✅ 完善的错误处理
|
|
|
-
|
|
|
-现在用户可以成功复制签名图片到剪贴板了!
|