files.ts 7.2 KB

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