Skip to content

Commit 993596b

Browse files
authored
feat: support display tiff and heic image (#321)
1 parent 3dc9a20 commit 993596b

6 files changed

Lines changed: 125 additions & 9 deletions

File tree

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
"framer-motion": "^12.6.3",
111111
"google-protobuf": "^3.15.12",
112112
"hast-util-to-mdast": "^10.1.0",
113+
"heic2any": "^0.0.4",
113114
"highlight.js": "^11.10.0",
114115
"html-parse-stringify": "^3.0.1",
115116
"i18next": "^22.4.10",
@@ -178,6 +179,7 @@
178179
"unist-util-visit": "^5.0.0",
179180
"unsplash-js": "^7.0.19",
180181
"utf8": "^3.0.0",
182+
"utif": "^3.1.0",
181183
"uuid": "^14.0.0",
182184
"validator": "^13.15.22",
183185
"y-indexeddb": "9.0.12",
@@ -223,6 +225,7 @@
223225
"@types/react-transition-group": "^4.4.6",
224226
"@types/react-window": "^1.8.8",
225227
"@types/utf8": "^3.0.1",
228+
"@types/utif": "^3.0.6",
226229
"@types/validator": "^13.11.9",
227230
"@typescript-eslint/eslint-plugin": "^7.2.0",
228231
"@typescript-eslint/parser": "^7.2.0",

pnpm-lock.yaml

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/application/database-yjs/fields/media/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { FileMediaType } from '@/application/database-yjs/cell.type';
55
* @param filename - The name or url of the file to check.
66
*/
77
export function getFileMediaType (filename: string) {
8-
const imgExtensionRegex = /\.(gif|jpe?g|tiff?|png|webp|bmp)$/i;
8+
const imgExtensionRegex = /\.(gif|jpe?g|tiff?|png|webp|bmp|heic|heif)$/i;
99
const videoExtensionRegex = /\.(mp4|mov|avi|webm|flv|m4v|mpeg|h264)$/i;
1010
const audioExtensionRegex = /\.(mp3|wav|ogg|flac|aac|wma|alac|aiff)$/i;
1111
const documentExtensionRegex = /\.(pdf|doc|docx)$/i;

src/components/_shared/image-upload/UploadImage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React, { useCallback } from 'react';
33
import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone';
44
import { notify } from '@/components/_shared/notify';
55

6-
export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'];
6+
export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'tif', 'tiff', 'heic', 'heif'];
77

88
export function UploadImage({
99
onDone,

src/utils/authenticated-image.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getTokenParsed } from '@/application/session/token';
22
import { isAppFlowyFileStorageUrl } from '@/utils/file-storage-url';
3+
import { transcodeIfUnsupported } from '@/utils/image';
34
import { Log } from '@/utils/log';
45
import { getConfigValue } from '@/utils/runtime-config';
56

@@ -43,7 +44,8 @@ export async function fetchAuthenticatedImage(url: string, token = getTokenParse
4344
}
4445

4546
const blob = await response.blob();
46-
const blobUrl = URL.createObjectURL(blob);
47+
const renderableBlob = await transcodeIfUnsupported(blob, url);
48+
const blobUrl = URL.createObjectURL(renderableBlob);
4749

4850
return blobUrl;
4951
} catch (error) {

src/utils/image.ts

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,73 @@ import { isAppFlowyFileStorageUrl } from '@/utils/file-storage-url';
33
import { Log } from '@/utils/log';
44
import { getConfigValue } from '@/utils/runtime-config';
55

6+
const HEIC_MIME_TYPES = new Set(['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence']);
7+
const TIFF_MIME_TYPES = new Set(['image/tiff', 'image/tif', 'image/x-tiff']);
8+
const HEIC_EXT_REGEX = /\.(heic|heif)(\?.*)?$/i;
9+
const TIFF_EXT_REGEX = /\.(tiff?)(\?.*)?$/i;
10+
11+
const isHeicBlob = (blob: Blob, url?: string): boolean => {
12+
if (HEIC_MIME_TYPES.has(blob.type)) return true;
13+
return !!url && HEIC_EXT_REGEX.test(url);
14+
};
15+
16+
const isTiffBlob = (blob: Blob, url?: string): boolean => {
17+
if (TIFF_MIME_TYPES.has(blob.type)) return true;
18+
return !!url && TIFF_EXT_REGEX.test(url);
19+
};
20+
21+
/**
22+
* Browsers (other than Safari) cannot decode HEIC/HEIF or TIFF natively.
23+
* Transcode such blobs to PNG client-side so a regular <img> can render them.
24+
*/
25+
export const transcodeIfUnsupported = async (blob: Blob, url?: string): Promise<Blob> => {
26+
try {
27+
if (isHeicBlob(blob, url)) {
28+
const heic2any = (await import('heic2any')).default;
29+
const result = await heic2any({ blob, toType: 'image/png' });
30+
31+
return Array.isArray(result) ? result[0] : result;
32+
}
33+
34+
if (isTiffBlob(blob, url)) {
35+
const utifMod = await import('utif');
36+
const UTIF = ((utifMod as unknown) as { default?: typeof import('utif') }).default ?? utifMod;
37+
const arrayBuffer = await blob.arrayBuffer();
38+
const ifds = UTIF.decode(arrayBuffer);
39+
40+
if (!ifds.length) {
41+
throw new Error('No image frames found in TIFF');
42+
}
43+
44+
UTIF.decodeImage(arrayBuffer, ifds[0]);
45+
const rgba = UTIF.toRGBA8(ifds[0]);
46+
const { width, height } = ifds[0];
47+
const canvas = document.createElement('canvas');
48+
49+
canvas.width = width;
50+
canvas.height = height;
51+
const ctx = canvas.getContext('2d');
52+
53+
if (!ctx) throw new Error('Failed to get canvas context');
54+
const clamped = new Uint8ClampedArray(rgba.buffer, rgba.byteOffset, rgba.byteLength);
55+
const imageData = new ImageData(clamped, width, height);
56+
57+
ctx.putImageData(imageData, 0, 0);
58+
59+
return await new Promise<Blob>((resolve, reject) => {
60+
canvas.toBlob((pngBlob) => {
61+
if (pngBlob) resolve(pngBlob);
62+
else reject(new Error('Failed to encode TIFF as PNG'));
63+
}, 'image/png');
64+
});
65+
}
66+
} catch (error) {
67+
Log.error('Failed to transcode unsupported image format', error);
68+
}
69+
70+
return blob;
71+
};
72+
673
const resolveImageUrl = (url: string): string => {
774
if (!url) return '';
875
return url.startsWith('http') ? url : `${getConfigValue('APPFLOWY_BASE_URL', '')}${url}`;
@@ -70,16 +137,18 @@ const validateImageBlob = async (blob: Blob, url?: string): Promise<Blob | null>
70137
return null;
71138
}
72139

140+
let normalizedBlob = blob;
141+
73142
// If the blob type is generic or missing, try to infer from URL
74-
if ((!blob.type || blob.type === 'application/octet-stream') && url) {
143+
if ((!normalizedBlob.type || normalizedBlob.type === 'application/octet-stream') && url) {
75144
const inferredType = getMimeTypeFromUrl(url);
76145

77146
if (inferredType) {
78-
return blob.slice(0, blob.size, inferredType);
147+
normalizedBlob = normalizedBlob.slice(0, normalizedBlob.size, inferredType);
79148
}
80149
}
81150

82-
return blob;
151+
return transcodeIfUnsupported(normalizedBlob, url);
83152
};
84153

85154
export const checkImage = async (url: string): Promise<CheckImageResult> => {
@@ -169,18 +238,18 @@ export const fetchImageBlob = async (url: string): Promise<Blob | null> => {
169238
const response = await fetch(url);
170239

171240
if (response.ok) {
172-
const blob = await response.blob();
241+
let blob = await response.blob();
173242

174243
// If the blob type is generic or missing, try to infer from URL
175244
if ((!blob.type || blob.type === 'application/octet-stream') && url) {
176245
const inferredType = getMimeTypeFromUrl(url);
177246

178247
if (inferredType) {
179-
return blob.slice(0, blob.size, inferredType);
248+
blob = blob.slice(0, blob.size, inferredType);
180249
}
181250
}
182251

183-
return blob;
252+
return transcodeIfUnsupported(blob, url);
184253
}
185254
} catch (error) {
186255
return null;
@@ -250,6 +319,15 @@ const getMimeTypeFromUrl = (url: string): string | null => {
250319
return 'image/webp';
251320
case 'svg':
252321
return 'image/svg+xml';
322+
case 'bmp':
323+
return 'image/bmp';
324+
case 'tif':
325+
case 'tiff':
326+
return 'image/tiff';
327+
case 'heic':
328+
return 'image/heic';
329+
case 'heif':
330+
return 'image/heif';
253331
default:
254332
return null;
255333
}

0 commit comments

Comments
 (0)