ファイルサイズで画像をリサイズする

<p class="codepen" data-height="800" data-default-tab="result" data-slug-hash="wvReVwx" data-user="04" style="height: 800px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/04/pen/wvReVwx">
  Resize images by size</a> by John Doe (<a href="https://codepen.io/04">@04</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

ファイルサイズを指定してリサイズできます。二分探索です。


```vue
<!-- Use preprocessors via the lang attribute! e.g. <template lang="pug"> -->
<template>
  <div id="app">
    <div class="demo-container">
      <div class="progress-bar" :class="{ invisible: !progress }">
        <div class="progress-bar-value"></div>
      </div>
    </div>
    <h1>Resize images By Size</h1>
    <input type="file" accept="image/*" @change="onchange" />
    <div class="file" v-for="file in files">
      <form @submit.prevent="resize(file)">
        <label>
          <input type="number" v-model="file.kb" :max="file.file.size / 1024" />
          KB
        </label>
        <button>Resize</button>
      </form>
      <table>
        <tbody>
          <tr v-for="variation in file.variations">
            <td>
              <img :src="variation.url" />
            </td>
            <td class="right">{{ bytesToSize(variation.size) }}</td>
            <td>
              <div class="actions">
                <button @click="download(variation.file.name, variation.url)">
                  Download
                </button>
                <button @click="copy(variation.blob)">Copy</button>
              </div>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
    <div>
      <h2>List of maximum file sizes</h2>
      <table class="size-table">
        <tbody>
          <tr>
            <td>GitHub Profile Picture</td>
            <td class="right">1MB</td>
          </tr>
          <tr>
            <td>Qiita Tag Icon</td>
            <td class="right">256KB</td>
          </tr>
          <tr>
            <td>Slack Emoji</td>
            <td class="right">128KB</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

<script>
const imgCache = {};
async function createImage(file) {
  if (imgCache[file.id]) return imgCache[file.id];
  const img = new Image();
  img.src = URL.createObjectURL(file);
  await new Promise((resolve) => img.addEventListener("load", resolve));
  imgCache[file.id] = img;
  return img;
}

const blobCache = {};
async function resize(file, width) {
  if (blobCache[file.id + width]) return blobCache[file.id + width];
  const img = await createImage(file);
  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = img.naturalHeight * (width / img.naturalWidth);
  const ctx = canvas.getContext("2d");
  ctx.imageSmoothingEnabled = false;
  ctx.drawImage(
    img,
    0,
    0,
    img.naturalWidth,
    img.naturalHeight,
    0,
    0,
    canvas.width,
    canvas.height
  );
  const blob = await new Promise((resolve) =>
    canvas.toBlob(resolve, file.type)
  );
  blobCache[file.id + width] = blob;
  return blob;
}

async function lowerBound(array, evaluate) {
  let first = 0,
    last = array.length - 1,
    middle;
  while (first <= last) {
    middle = Math.floor((first + last) / 2);
    const evaluated = await evaluate(array[middle]);
    if (evaluated < 0) first = middle + 1;
    else last = middle - 1;
  }
  return array[first - 1];
}

function range(min, max, step = 1) {
  return Array.from(
    { length: (max - min + step) / step },
    (v, k) => min + k * step
  );
}
const gcd = (a, b) => (!b ? a : gcd(b, a % b));
async function optimize(file, size) {
  const img = await createImage(file);
  const gcdValue = gcd(img.naturalWidth, img.naturalHeight);
  const minWidth = gcdValue === 1 ? 8 : img.naturalWidth / gcdValue;
  let widths = range(minWidth, img.naturalWidth, minWidth);
  const width = await lowerBound(widths, async (width) => {
    const blob = await resize(file, width);
    return blob.size - size;
  });
  return resize(file, width);
}

const KB = 2 ** 10;
const MB = KB ** 2;
const GB = KB ** 3;

export default {
  data() {
    return {
      files: [],
      progress: false
    };
  },
  computed: {
    bytesToSize() {
      return (bytes) => {
        const sizes = ["B", "KB", "MB", "GB", "TB"];
        if (bytes == 0) return "0 B";
        const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
        if (i == 0) return bytes + " " + sizes[i];
        return (bytes / Math.pow(1024, i)).toFixed(1) + " " + sizes[i];
      };
    }
  },
  methods: {
    async onchange(e) {
      if (!e.target.files.length) return;
      this.progress = true;
      for await (const file of e.target.files) {
        file.id = Math.random();
        const entry = {
          file,
          variations: [
            {
              file,
              blob: file,
              url: URL.createObjectURL(file),
              size: file.size
            }
          ]
        };
        this.files.push(entry);
        for await (const size of [MB, 256 * KB, 128 * KB]) {
          if (file.size < size) continue;
          const blob = await optimize(file, size);
          entry.variations.push({
            file,
            blob,
            url: URL.createObjectURL(blob),
            size
          });
        }
        this.progress = false;
      }
    },
    async resize(file) {
      const size = 1024 * file.kb;
      const blob = await optimize(file.file, size);
      file.variations.push({
        file: file.file,
        blob,
        url: URL.createObjectURL(blob),
        size
      });
    },
    async copy(blob) {
      try {
        console.log(blob);
        await navigator.clipboard.write([
          new ClipboardItem({
            [blob.type]: blob
          })
        ]);
        Toastify({ text: "Copied!", duration: 10000 }).showToast();
      } catch (e) {
        Toastify({ text: e, duration: 10000 }).showToast();
        Toastify({
          text: "Right click on the image & Copy image",
          duration: 10000
        }).showToast();
      }
    },
    download(filename, url) {
      const a = document.createElement("a");
      a.download = filename;
      a.href = url;
      a.click();
    }
  }
};
</script>

<!-- Use preprocessors via the lang attribute! e.g. <style lang="scss"> -->
<style>
#app {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}
.file {
  border: 1px solid #eee;
  border-radius: 4px;
  padding: 16px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
img {
  width: 256px;
  vertical-align: middle;
}

@media screen and (max-width: 480px) {
  .file {
    padding: 8px;
  }
  img {
    width: 196px;
  }
}

.right {
  text-align: right;
}

.size-table {
  margin: 0 auto;
  font-size: 24px;
}

.actions {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 16px;
}

body {
  margin: 0;
}

.demo-container {
  width: 100%;
  margin: auto;
}

.progress-bar {
  height: 8px;
  background-color: rgba(5, 114, 206, 0.2);
  width: 100%;
  overflow: hidden;
}

.progress-bar-value {
  width: 100%;
  height: 100%;
  background-color: rgb(5, 114, 206);
  animation: indeterminateAnimation 1s infinite linear;
  transform-origin: 0% 50%;
}

@keyframes indeterminateAnimation {
  0% {
    transform: translateX(0) scaleX(0);
  }
  40% {
    transform: translateX(0) scaleX(0.4);
  }
  100% {
    transform: translateX(100%) scaleX(0.5);
  }
}

.invisible {
  visibility: hidden;
}
</style>
```