@@ -3,6 +3,73 @@ import { isAppFlowyFileStorageUrl } from '@/utils/file-storage-url';
33import { Log } from '@/utils/log' ;
44import { 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 = / \. ( h e i c | h e i f ) ( \? .* ) ? $ / i;
9+ const TIFF_EXT_REGEX = / \. ( t i f f ? ) ( \? .* ) ? $ / 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+
673const 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
85154export 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