files.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. import type { FileUploaded } from '#shared/components/Form/fields/FieldFile/types.ts'
  3. import { useApplicationStore } from '#shared/stores/application.ts'
  4. import log from './log.ts'
  5. import type { Except } from 'type-fest'
  6. export type FilePreview = 'image' | 'calendar'
  7. export interface ImageFileData {
  8. name: string
  9. type: string
  10. content: string
  11. }
  12. export interface ImageFileSource extends Except<ImageFileData, 'content'> {
  13. name: string
  14. type: string
  15. src: string
  16. }
  17. interface CompressData {
  18. x?: number | 'auto'
  19. y?: number | 'auto'
  20. scale?: number
  21. type?: string
  22. quality?: number | 'auto'
  23. }
  24. interface CompressOptions {
  25. compress?: boolean
  26. onCompress?(image: HTMLImageElement, type: string): CompressData
  27. }
  28. interface ValidatedFile {
  29. file: File
  30. label: string
  31. maxSize: number
  32. allowedTypes: string[]
  33. }
  34. export interface AllowedFile {
  35. label: string
  36. types: string[]
  37. size: number
  38. }
  39. const allowCompressMime = ['image/jpeg', 'image/png']
  40. const getQuality = (x: number, y: number) => {
  41. if (x < 200 && y < 200) return 1
  42. if (x < 400 && y < 400) return 0.9
  43. if (x < 600 && y < 600) return 0.8
  44. if (x < 900 && y < 900) return 0.7
  45. return 0.6
  46. }
  47. export const compressImage = (
  48. imageSrc: string,
  49. type: string,
  50. options?: CompressOptions,
  51. ) => {
  52. const img = new Image()
  53. // eslint-disable-next-line sonarjs/cognitive-complexity
  54. const promise = new Promise<string>((resolve) => {
  55. img.onload = () => {
  56. const {
  57. x: imgX = 'auto',
  58. y: imgY = 'auto',
  59. quality = 'auto',
  60. scale = 1,
  61. type: mimeType = type,
  62. } = options?.onCompress?.(img, type) || {}
  63. const imageWidth = img.width
  64. const imageHeight = img.height
  65. log.debug('[Image Service] Image is loaded', {
  66. imageWidth,
  67. imageHeight,
  68. })
  69. let x = imgX
  70. let y = imgY
  71. if (y === 'auto' && x === 'auto') {
  72. x = imageWidth
  73. y = imageHeight
  74. }
  75. // set max x/y
  76. if (x !== 'auto' && x > imageWidth) x = imageWidth
  77. if (y !== 'auto' && y > imageHeight) y = imageHeight
  78. // get auto dimensions
  79. if (y === 'auto') {
  80. const factor = imageWidth / (x as number)
  81. y = imageHeight / factor
  82. }
  83. if (x === 'auto') {
  84. const factor = imageHeight / y
  85. x = imageWidth / factor
  86. }
  87. const canvas = document.createElement('canvas')
  88. if (
  89. (x < imageWidth && x * scale < imageWidth) ||
  90. (y < imageHeight && y * scale < imageHeight)
  91. ) {
  92. x *= scale
  93. y *= scale
  94. // set dimensions
  95. canvas.width = x
  96. canvas.height = y
  97. // draw image on canvas and set image dimensions
  98. const context = canvas.getContext('2d') as CanvasRenderingContext2D
  99. context.drawImage(img, 0, 0, x, y)
  100. } else {
  101. canvas.width = imageWidth
  102. canvas.height = imageHeight
  103. const context = canvas.getContext('2d') as CanvasRenderingContext2D
  104. context.drawImage(img, 0, 0, imageWidth, imageHeight)
  105. }
  106. const qualityValue =
  107. quality === 'auto' ? getQuality(imageWidth, imageHeight) : quality
  108. try {
  109. const base64 = canvas.toDataURL(mimeType, qualityValue)
  110. log.debug('[Image Service] Image is compressed', {
  111. quality: qualityValue,
  112. type: mimeType,
  113. x,
  114. y,
  115. size: `${(base64.length * 0.75) / 1024 / 1024} Mb`,
  116. })
  117. resolve(base64)
  118. } catch (e) {
  119. log.debug('[Image Service] Failed to compress an image', e)
  120. resolve(imageSrc)
  121. }
  122. }
  123. img.onerror = () => resolve(imageSrc)
  124. })
  125. img.src = imageSrc
  126. return promise
  127. }
  128. export const blobToBase64 = async (blob: Blob) =>
  129. new Promise<string>((resolve, reject) => {
  130. const reader = new FileReader()
  131. reader.onload = () => resolve(reader.result as string)
  132. reader.onerror = () => reject(reader.error)
  133. reader.readAsDataURL(blob)
  134. })
  135. export const dataURLToBlob = (dataURL: string) => {
  136. const byteString = window.atob(dataURL.split(',')[1])
  137. const mimeString = dataURL.split(',')[0].split(':')[1].split(';')[0]
  138. // Create Uint8Array directly from the byteString
  139. const ia = Uint8Array.from(byteString, (c) => c.charCodeAt(0))
  140. return new Blob([ia], { type: mimeString })
  141. }
  142. export const convertFileList = async (
  143. filesList: Maybe<FileList | File[]>,
  144. options: CompressOptions = {},
  145. ): Promise<ImageFileData[]> => {
  146. const files = Array.from(filesList || [])
  147. const promises = files.map(async (file) => {
  148. let base64 = await blobToBase64(file)
  149. if (options?.compress && allowCompressMime.includes(file.type)) {
  150. base64 = await compressImage(base64, file.type, options)
  151. }
  152. return {
  153. name: file.name,
  154. type: file.type,
  155. content: base64,
  156. }
  157. })
  158. const readFiles = await Promise.all(promises)
  159. return readFiles.filter((file) => file.content)
  160. }
  161. export const loadImageIntoBase64 = async (
  162. src: string,
  163. type?: string,
  164. alt?: string,
  165. ): Promise<string | null> => {
  166. const img = new Image()
  167. img.crossOrigin = 'anonymous'
  168. const promise = new Promise<string | null>((resolve) => {
  169. img.onload = () => {
  170. const canvas = document.createElement('canvas')
  171. canvas.width = img.width
  172. canvas.height = img.height
  173. const ctx = canvas.getContext('2d')
  174. ctx?.drawImage(img, 0, 0, img.width, img.height)
  175. const mime =
  176. type || (img.alt?.match(/\.(jpe?g)$/i) ? 'image/jpeg' : 'image/png')
  177. try {
  178. const base64 = canvas.toDataURL(mime)
  179. resolve(base64)
  180. } catch {
  181. resolve(null)
  182. }
  183. }
  184. img.onerror = () => {
  185. resolve(null)
  186. }
  187. })
  188. img.alt = alt || ''
  189. img.src = src
  190. return promise
  191. }
  192. export const canDownloadFile = (type?: Maybe<string>) => {
  193. return Boolean(type && type !== 'text/html')
  194. }
  195. export const allowedImageTypes = () => {
  196. const { config } = useApplicationStore()
  197. return config['active_storage.web_image_content_types'] || []
  198. }
  199. export const allowedImageTypesString = () => {
  200. return allowedImageTypes().join(',')
  201. }
  202. export const canPreviewFile = (type?: Maybe<string>): FilePreview | false => {
  203. if (!type) return false
  204. if (allowedImageTypes().includes(type)) return 'image'
  205. if (type === 'text/calendar') return 'calendar'
  206. return false
  207. }
  208. export const convertFilesToAttachmentInput = (
  209. formId: string,
  210. attachments?: FileUploaded[],
  211. ) => {
  212. const files = attachments?.map((file) => ({
  213. name: file.name,
  214. type: file.type,
  215. }))
  216. if (!files || !files.length) return null
  217. return {
  218. files,
  219. formId,
  220. }
  221. }
  222. /**
  223. * @param file - file Size is in bytes
  224. * @param allowedSize - allowed size in bytes
  225. * * */
  226. export const validateFileSizeLimit = (file: File, allowedSize: number) => {
  227. return file.size <= allowedSize
  228. }
  229. export const validateFileSizes = (
  230. files: File[],
  231. allowedFiles: AllowedFile[],
  232. ) => {
  233. const failedFiles: Omit<ValidatedFile, 'allowedTypes'>[] = []
  234. files.forEach((file) => {
  235. allowedFiles.forEach((allowedFile) => {
  236. if (!allowedFile.types.includes(file.type)) return
  237. if (!validateFileSizeLimit(file, allowedFile.size))
  238. failedFiles.push({
  239. file,
  240. label: allowedFile.label,
  241. maxSize: allowedFile.size,
  242. })
  243. })
  244. })
  245. return failedFiles
  246. }
  247. /**
  248. * @return {string} - A string of acceptable file types for input element.
  249. * * */
  250. export const getAcceptableFileTypesString = (
  251. allowedFiles: AllowedFile[],
  252. ): string => {
  253. const result: Set<string> = new Set([])
  254. allowedFiles.forEach((file) => {
  255. file.types.forEach((type) => {
  256. result.add(type)
  257. })
  258. })
  259. return Array.from(result).join(', ')
  260. }
  261. /**
  262. * @param size file size in bytes
  263. ** */
  264. export const humanizeFileSize = (size: number) => {
  265. if (size > 1024 * 1024 * 1024) {
  266. return `${Math.round((size * 10) / (1024 * 1024)) / 10} MB`
  267. }
  268. if (size > 1024) {
  269. return `${Math.round(size / 1024)} KB`
  270. }
  271. return `${size} Bytes`
  272. }