index.js

/**
 * @file 又拍云图片处理工具插件
 * @author lisfan <goolisfan@gmail.com>
 * @version 2.0.0
 * @licence MIT
 */

import validation from '@~lisfan/validation'
import Logger from '@~lisfan/logger'

import getNetworkType from './utils/get-network-type'
import isWebp from './utils/webp-features-support'

let UPYunImageFormat = {} // 插件对象
const PLUGIN_TYPE = 'filter' // 插件类型
const FILTER_NAMESPACE = 'image-format' // 过滤器名称

/**
 * 又拍云缩放方式,各种缩放规则所需要的尺寸参数长度
 *
 * @ignore
 * @enum {number}
 */
const SCALE_PARAM_LEN = {
  fw: 1,
  fh: 1,
  max: 1,
  min: 1,
  fwfh: 2,
  fwfh2: 2,
  both: 2,
  sq: 1,
  scale: 1,
  wscale: 1,
  hscale: 1,
  fxfn: 2,
  fxfn2: 2,
  fp: 1,
}

const FORMAT_RULES = {
  compress: /jpg|jpeg|png/,
  format: /jpg|jpeg|png|webp/,
  progressive: /jpg|jpeg/,
  quality: /jpg|jpeg/,
  lossless: /webp/,
}

// 私有方法
const _actions = {
  /**
   * 获取图片后缀
   * 【注】假设图片文件末尾都是以存在后缀的,未兼容处理那些不带后缀形式的图片文件
   *
   * @since 2.0.0
   * @param {string} filename - 文件名称
   * @returns {string}
   */
  getFileExtension(filename) {
    const extReg = /\.([^.]*)$/
    const matched = filename.match(extReg)

    if (!matched) {
      return ''
    }

    return matched[1]
  },
  /**
   * 根据当前的网络制式,获取最终的DPR值
   *
   * @since 2.0.0
   * @param {string} networkType - 网络制式类型
   * @param {number} DPR - 设备像素比
   * @param {number} maxDPR - 支持的最大设备像素比
   * @returns {number}
   */
  getFinalDPR(networkType, DPR, maxDPR) {
    if (networkType === '4g' || networkType === 'unknow') {
      return DPR >= maxDPR ? maxDPR : DPR
    } else if (networkType === 'wifi') {
      return DPR
    } else {
      return 1
    }
  },
  /**
   * 获取最终的图片尺寸
   *
   * @since 2.0.0
   * @param {?number|string} size - 自定义尺寸
   * @param {string} finalScale - 最终缩放格式
   * @param {number} finalDPR - 最终DPR值
   * @param {number} draftRatio - 物理尺寸和UI草稿比
   * @returns {?string}
   */
  getFinalSize(size, finalScale, finalDPR, draftRatio) {
    // 是否存在有效的尺寸值
    let isValidSize = false

    let finalSizeList = []

    if (!validation.isNil(size)) {
      const sizeList = size.toString().split('x')

      finalSizeList = sizeList.map((sizeItem) => {
        return Math.round((Number.parseFloat(sizeItem) / draftRatio) * finalDPR)
      })

      // 查看是否为数字格式
      finalSizeList = finalSizeList.filter((size) => {
        return validation.isNumber(size)
      })

      const paramLen = SCALE_PARAM_LEN[finalScale]
      // 截取缩放方式需要的缩放规格长度
      finalSizeList = finalSizeList.slice(0, paramLen)
      const sizeLen = finalSizeList.length
      isValidSize = sizeLen > 0

      if (paramLen > sizeLen) {
        const quotient = Math.ceil(sizeLen / paramLen) + 1

        finalSizeList = (finalSizeList.toString() + ',').repeat(quotient).slice(0, -1).split(',')
        finalSizeList = finalSizeList.slice(0, paramLen)
      }
    }

    return isValidSize ? finalSizeList.join('x') : null
  },
  /**
   * 根据条件确定默认输出图片格式
   *
   * - 根据浏览器对webp的支持力度及其他一些情况,用户上传的图片如果是非webp格式或jpg格式,如源图是png的,则会按照以下不同的场景转换成webp或jpg
   * - (静态,支持有损webp,图片宽小于设备物理分辨率*dpr的2分之1时或小于200px*dpr时),使用webp格式(又拍云api: `/format/webp`)
   *  - (静态,支持有损webp,图片宽大于设备物理分辨率*dpr的2分之1时且大于200px*dpr时),使用jpg格式(又拍云api: `/format/jpg`)
   *  - (静态,不支持webp),使用jpg格式(又拍云api: `/format/jpg`)
   *  - (动态,支持动态webp时),使用webp格式(又拍云api: `/format/webp`)
   *  - (动态,不支持动态webp时),使用gif格式,不作变动
   *
   * @since 2.0.0
   * @param {string} originFormat - 原格式
   * @param {number} width - 用户自定义的宽度
   * @param {number} minWidth - 最小宽度
   * @returns {string}
   */
  computeDefaultFormat(originFormat, width, minWidth) {
    // 如果源文件是动态图片且支持动态webp时,则转换为webp
    if (originFormat === 'gif') {
      return isWebp.support('animation') ? 'webp' : 'gif'
    } else {
      // 如果支持静态webp
      return isWebp.support('lossy') && width >= 0 && minWidth >= width ? 'webp' : 'jpg'
    }
  },
  /**
   * 根据条件,获取最终的图片格式
   *
   * @since 2.0.0
   * @param {string} format - 自定义格式
   * @param {string} originFormat - 原格式
   * @param {?number} width - 自定义尺寸
   * @param {number} minWidth - 最小尺寸
   * @param {boolean} [lossless] - 是否转换为无损webp格式
   * @returns {string}
   */
  getFinalFormat(format, originFormat, width, minWidth, lossless) {
    // 若未自定义图片格式,则根据一些条件,设置默认格式
    // 若自定义了图片格式,且不是指定为webp格式,则直接返回指定的格式
    // 若指定为webp格式
    //   - 若源图片gif格式,则检测是否支持动态webp格式,
    //   - 若源图片jpg或png格式,则检测是否支持有损webp格式或无损webp格式
    // 如果指定了自定义值,则使用自定义值
    if (!validation.isString(format)) {
      return _actions.computeDefaultFormat(originFormat, width, minWidth)
    }

    if (format === 'webp') {
      // 若源图片gif格式,则检测是否支持动态webp格式,若不支持则转换为gif格式
      //   - 若源图片jpg或png格式,则检测是否支持有损webp格式或无损webp格式
      if (originFormat === 'gif' && !isWebp.support('animation')) {
        return 'gif'
      } else if (originFormat.match(/jpeg|jpg|png/) && !lossless && !isWebp.support('lossy')) {
        return 'jpg'
      } else if (originFormat.match(/jpeg|jpg|png/) && lossless && !isWebp.support('lossless')) {
        return 'jpg'
      } else {
        return 'webp'
      }
    }

    // 返回源格式
    return format
  },
  /**
   * 获取自定义的其他又拍云规则项
   * [注] 规则项需要有对应关系,不可随意填写
   *
   * @since 2.0.0
   * @param {object} otherRules- 其他又拍云规则项
   * @returns {object}
   */
  getFinalOtherRules(otherRules) {
    if (validation.isEmpty(otherRules)) {
      return {}
    }

    // 如果本身已是对象格式,则直接返回
    if (validation.isPlainObject(otherRules)) {
      return otherRules
    }

    otherRules = otherRules.startsWith('/') ? otherRules.slice(1) : otherRules

    const rules = otherRules.split('/')

    // 分割后的的数据长度能被2整除,无余数
    if (rules.length % 2 !== 0) {
      throw new Error('other rules is\'t parse! please check')
    }

    const finalOtherRules = {}

    rules.forEach((value, index) => {
      if (index % 2 === 0) {
        finalOtherRules[value] = rules[index + 1]
      }
    })

    return finalOtherRules
  },
  /**
   * 过滤为undefined、null及空值
   *
   * @since 2.0.0
   * @param {object} rules - 规则配置
   * @returns {object}
   */
  filterRules(rules) {
    let filterRules = {}
    Object.entries(rules).forEach(([key, value]) => {
      if (!validation.isNil(value) || !validation.isEmpty(value)) {
        filterRules[key] = value
      }
    })

    return filterRules
  },
  /**
   * 根据图片格式进一步优化规则
   * 目前只有两条规则,所以不采用策略,直接进行逻辑判断
   *
   * @since 2.0.0
   * @param {object} rules - 优化规则
   * @return {object}
   */
  optimizeRules(rules) {
    // 若未jpg格式,且不存在渐进加载的配置时,
    if (!validation.isBoolean(rules.progressive) && rules.format === 'jpg') {
      rules.progressive = true
    } else if (!validation.isBoolean(rules.compress) && rules.format === 'png') {
      rules.compress = true
    }

    return rules
  },
  /**
   * 针对图片格式,过滤规则
   *
   * 移除某些针对与具体格式或者属性时的规则
   * - 如compress只能用在jpg和png上
   * - 如format不支持值是gif
   * - 如progressive只支持jpg
   * - 如quality只支持jpg
   * - 如lossless只支持webp
   *
   * @since 2.0.2
   * @param {object} rules - 规则配置
   * @return {object}
   */
  filterRulesByFormat(rules) {
    const format = rules.format
    // 未匹配到时进行过滤
    Object.entries(FORMAT_RULES).forEach(([key, regexp]) => {
      const matched = format.match(regexp)

      if (!matched) {
        rules[key] = null
      }
    })

    return rules
  },
  /**
   * 序列化规则为符合格式的字符串
   *
   * @since 2.0.0
   * @param {object} rules - 规则配置
   * @returns {string}
   */
  stringifyRule(rules) {
    const matchedRules = _actions.filterRulesByFormat(rules)
    let filterRules = _actions.filterRules(matchedRules)

    // 处理针对格式的优化
    filterRules = _actions.optimizeRules(filterRules)

    // 不存在值时,直接返回空字符串
    if (Object.keys(filterRules).length === 0) {
      return ''
    }

    // 提前取出缩放方式(scale)和尺寸(size)进行额外的处理,其他值做拼接
    let imageSrc = validation.isNil(filterRules.size) ? '' : `/${filterRules.scale}/${filterRules.size}`

    // 规则按key名进行排序:解决相同的优化字段时,因key的顺序不同而造成再次进行图片处理
    const sortedRules = Object.entries(filterRules).sort(([prevKey], [nextKey]) => {
      return prevKey > nextKey
    })

    imageSrc += sortedRules.reduce((result, [key, value]) => {
      if (key === 'size' || key === 'scale') {
        return result
      }

      return result + `/${key}/${value}`
    }, '')

    // 规则至少存在一项时,则加上前缀`!`修饰符号
    return validation.isEmpty(imageSrc) ? '' : '!' + imageSrc
  }
}

/**
 * 又拍云图片处理工具注册函数
 * 处理规则请参考[又拍云文档](http://docs.upyun.com/cloud/image/#webp)
 *
 * 若想针对某个值使用默认值,则传入null值即可
 *
 * @global
 * @since 2.0.0
 * @param {Vue} Vue - Vue类
 * @param {object} [options={}] - 配置选项
 * @param {boolean} [options.debug=false] - 是否开启日志调试模式
 * @param {number} [options.maxDPR=3] - (>=4)g网络或者'unknow'未知网络下,DPR取值的最大数
 * @param {number} [options.draftRatio=2] - UI设计稿尺寸与设备物理尺寸的比例
 * @param {string} [options.scale='both'] - 又拍云图片尺寸缩放方式,默认宽度进行自适应,超出尺寸进行裁剪,若自定义尺寸大于原尺寸时,自动缩放至指定尺寸再裁剪
 * @param {number} [options.quality=90] - 又拍云jpg格式图片压缩质量
 * @param {string} [options.rules=''] - 又拍云图片处理的其他规则
 * @param {number} [options.minWidth] - 默认值是(当前设备的物理分辨率 * 当前实际设备像素比的) 二分之一
 * @param {function} [options.networkHandler='unknow'] - 获取网络制式的处理函数,若不存在,返回unknow
 */
UPYunImageFormat.install = async function (Vue, {
  debug = false,
  maxDPR = 3,
  draftRatio = 2,
  scale = 'both',
  quality = 90,
  rules = '',
  minWidth = global.document.documentElement.clientWidth * global.devicePixelRatio / 2,
  networkHandler = getNetworkType
} = {}) {
  const logger = new Logger({
    name: `${PLUGIN_TYPE}:${FILTER_NAMESPACE}`,
    debug
  })

  // 插件注册时验证是否会存在网络制式处理器,若不存在,则抛出错误
  if (!validation.isFunction(networkHandler)) {
    logger.error(`Vue plugin install faild! require a (networkHandler) option property, type is (function), please check!`)
  }

  /**
   * vue过滤器:image-format
   *
   * @global
   * @function image-format
   * @since 2.0.0
   * @param {string} src - 图片地址,第一个参数vue组件会自动传入,无须管理
   * @param {?number|string|object} [sizeOrConfig] - 裁剪尺寸,取设计稿中的尺寸即可,该值如果是一个字典格式的配置对象,则会其他参数选项的值
   * @param {?string} [scale='both'] - 缩放方式
   * @param {?string} [format] - 图片格式化,系统会根据多种情况来最终确定该值的默认值
   * @param {?number} [quality=90] - 若输出jpg格式图片时的压缩质量
   * @param {?string|object} [rules=''] -
   *   又拍云图片处理的的其他规则,注意,如果它是一个字符串格式是,那么它必须是采用`/[key]/[value]`这样的写法,不能随意乱写,同时这里的值不会覆盖前几个参数的值,该项的优先级最低
   */
  Vue.filter(FILTER_NAMESPACE, (src, sizeOrConfig, customScale = scale, customformat, customQuality = quality, customOtherRules = rules) => {
    // 如果未传入图片地址,则直接返回空值
    if (validation.isUndefined(src) || validation.isEmpty(src)) {
      return ''
    }

    const DPR = global.devicePixelRatio || 1 // 当前设备的DPR值
    const networkType = networkHandler() || 'unknow' // 当前网络制式,每次重新获取,因网络随时会变化

    // 如果size是一个对象,则表示是以字典的方式进行配置参数
    let originSize, originScale, originFormat, originQuality, originOtherRules

    if (validation.isPlainObject(sizeOrConfig)) {
      const { size: sizeOption, scale: scaleOption, format: formatOption, quality: qualityOption, ...otherRulesOption } = sizeOrConfig

      originSize = sizeOption
      originScale = scaleOption
      originFormat = formatOption
      originQuality = qualityOption
      originOtherRules = otherRulesOption
    } else {
      originSize = sizeOrConfig
      originScale = customScale
      originFormat = customformat
      originQuality = customQuality
      originOtherRules = customOtherRules
    }

    logger.log('network:', networkType)
    logger.log('origin image src:', src)
    logger.log('origin image size:', originSize)
    logger.log('origin image format:', originFormat)
    logger.log('origin image scale:', originScale)
    logger.log('origin image quality:', originQuality)
    logger.log('origin rules:', originOtherRules)

    // 最终的其他规则
    let finalRules = _actions.getFinalOtherRules(originOtherRules)
    // 最终的DPR
    let finalDPR = _actions.getFinalDPR(networkType, DPR, maxDPR)
    // 最终的缩放取值
    let finalScale = originScale || scale
    // 最终的质量取值
    let finalQuality = originQuality || quality
    // 最终的图片尺寸
    let finalSize = _actions.getFinalSize(originSize, finalScale, finalDPR, draftRatio)

    // 获取图片宽度
    let width
    if (validation.isString(finalSize)) {
      width = finalSize.split('x')[0]
    }

    // 最终的图片格式
    let finalFormat = _actions.getFinalFormat(originFormat, _actions.getFileExtension(src), width, minWidth, finalRules.lossless)

    logger.log('final image size:', finalSize)
    logger.log('final image DPR:', finalDPR)
    logger.log('final image scale:', finalScale)
    logger.log('final image quality:', finalQuality)
    logger.log('final image format:', finalFormat)
    logger.log('final image custom rules:', finalRules)

    // 序列化规则
    const stringifyRule = _actions.stringifyRule({
      ...finalRules,
      format: finalFormat,
      scale: finalScale, // 缩放规格
      size: finalSize, // 图片尺寸
      quality: finalQuality, // jpg图片压缩质量
    })

    logger.log('final image rule:', stringifyRule)

    // 拼接最终的结果
    let finalSrc = src + stringifyRule

    logger.log('final image src:', finalSrc)

    return finalSrc
  })
}

export default UPYunImageFormat