基于Canvas的图片切割器

2022-10-24 17:02:04 浏览数 (1)

背景

先上地址:https://kifuan.github.io/photo-cutter/

由于某群友有切割图片放到个人资料里面的需求,所以我就顺手写了一个这样的项目。

效果

如下:

原理

来源: MDN

利用CanvasRenderingContext2D.drawImage对图片进行处理,API如下:

代码语言:javascript复制
ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)

用下面这张图就可以解释清楚:

因为我们并不需要调整这么多的参数,举个栗子,把一张300x400的从100, 200的位置截出一个100x100的图片,我们需要这么做:

代码语言:javascript复制
ctx.drawImage(image, 100, 200, 100, 100, 0, 0, 100, 100)

并不需要记住每个参数的位置,毕竟记不住可以查文档嘛。

实现

目标

目标其实很简单,我们要把一张大图切割成下面的形式:

忽略我作画的渣水平,就当每个格子都是正方形,所以说它的宽高比应当是3:4

策略模式的应用

因为我们有多种切割图片的策略,所以这里可以应用策略模式

说白了就是把那一堆参数放到一个对象里面,如下:

代码语言:javascript复制
// src/stores/strategy.ts

export interface Strategy {
  // 显示在下拉框里的文本
  label: string
  // 处理时以 unit * 理想宽度 为一个单位
  unit: number
  // 理想宽高比
  scale: number
  // 每个步骤
  steps: StrategyStep[]
}

export interface StrategyStep {
  // 显示在canvas上方的文本
  label: string
  // 图片的长和宽是几个实际单位长度
  size: number
  // 图片x y坐标的offset
  offset: [number, number]
}

export const strategies: Record<string, Strategy> = {
  qq3x3: {
    label: 'QQ个人资料图片3x3',
    // 由于横着切三份,所以单位长度是三分之一
    unit: 0.333333,
    // 理想宽高比为3:4
    scale: 0.75,
    steps: [
      {
        label: '左1',
        size: 2,
        offset: [0, 0],
      },
      {
        label: '右1',
        size: 1,
        offset: [2, 0],
      },
      {
        label: '右2',
        size: 1,
        offset: [2, 1],
      },
      {
        label: '左2',
        size: 1,
        offset: [0, 2],
      },
      {
        label: '中1',
        size: 1,
        offset: [1, 2],
      },
      {
        label: '右3',
        size: 1,
        offset: [2, 2],
      },
      {
        label: '左3',
        size: 1,
        offset: [0, 3],
      },
      {
        label: '中2',
        size: 1,
        offset: [1, 3],
      },
      {
        label: '右4',
        size: 1,
        offset: [2, 3],
      },
    ],
  },
  // 另外两个略,可以自行去Github仓库查看
}

计算标准长度

看起来挺吓人,实际上核心很简单,就是根据期望比例算出来标准数据,实际上是一个数学问题。

代码语言:javascript复制
// src/components/PhotoFragments.vue

// 源码中没有这个类型,为了在本文中显得美观一点,我就又提出来了一个Data类型
interface Data {
  unit: number
  cutOffset: [number, number]
}

function calcRegularData(image: HTMLImageElement, strategy: Strategy) : Data {
  const scale = image.width / image.height
  if (scale > strategy.scale) {
    // 实际宽高比 > 理想宽高比,意味着这个图片太宽了
    // 所以用实际长度 * 理想宽高比 算出来理想宽度
    const idealWidth = image.height * strategy.scale
    // 相应地,单位长度就是根据理想宽度算出来的了
    const unit = idealWidth * strategy.unit
    // 图片太宽了,我们保留中间部分,裁掉左右两边
    const cutOffsetX = (image.width - idealWidth) / 2
    return { unit, cutOffset: [ cutOffsetX, 0 ] }
  }
  else {
    // 走到这里意味着这个图片太窄了
    // 所以用实际宽度 / 理想宽高比 算出来理想高度
    const idealHeight = image.width / strategy.scale
    // 因为宽度是符合要求的,所以直接用实际宽度计算出单位长度
    const unit = image.width * strategy.unit
    // 图片太窄了,裁掉上下两边
    const cutOffsetY = (image.height - idealHeight) / 2
    return { unit, cutOffset: [ 0, cutOffsetY ] }
  }
}

画到canvas上

在此之前,我们先给出Vue模板代码,用来动态生成canvas

代码语言:javascript复制
<template>
  <div v-for="(step, index) in strategy.steps" :key="index" class="fragment">
    <p>{{ step.label }}</p>
    <canvas
      :ref="el => { canvases[index] = el as HTMLCanvasElement }"
    />
    <button @click="handleDownload(index)">
      下载
    </button>
  </div>
</template>

<style scoped>
.fragment {
    display: flex;
    flex-direction: column;
}
.fragment > * {
    margin-bottom: 15px;
}
</style>

在这里,我向ref里面传了一个函数,它可以把canvasDOM保存到canvases这个数组里面。

因为前面还定义了一些变量,这里不方便进行代码块的截取,索性都贴出来了,除了calcRegularData这个函数。

代码语言:javascript复制
// src/components/PhotoFragments.vue

import { storeToRefs } from 'pinia'
import { computed, watch } from 'vue'
import { useImageStore } from '../stores/image'
import { strategies, Strategy, useStrategyStore } from '../stores/strategy'
// 下面这些都是pinia的store
const imageStore = useImageStore()
const strategyStore = useStrategyStore()
const strategy = computed(() => {
  return strategies[strategyStore.strategy]
})
const canvases = [] as HTMLCanvasElement[]

watch(storeToRefs(imageStore).image, (image) => {
  if (image === undefined)
    return
  // 先把标准长度计算出来
  const { unit, cutOffset: [ cutOffsetX, cutOffsetY ] } = calcRegularData(image, strategy.value)
  for (let i = 0; i < canvases.length; i  ) {
    const { size, offset: [offsetX, offsetY] } = strategy.value.steps[i]
    const canvas = canvases[i]
    const ctx = canvas.getContext('2d')!
    // 先设置canvas的长宽
    canvas.width = canvas.height = size * unit
    // 把这个图片根据参数画到canvas上
    ctx.drawImage(image, unit * offsetX   cutOffsetX, unit * offsetY   cutOffsetY,
    	unit * size, unit * size, 0, 0, unit * size, unit * size)
  }
})

function handleDownload(canvasIndex: number) {
  const a = document.createElement('a')
  // 下面这串代码是随机生成一个字符串,作为下载的文件名
  a.download = Math.random().toString(36).substring(2)
  a.href = canvases[canvasIndex].toDataURL()
  a.click()
}

就这样,核心代码就这些,store定义的部分我就省去了,都是大同小异的代码。

0 人点赞