コーディングもするデザイナーが知っておきたい SVG 最適化（svgo v3 対応）

Docker Desktop と SVGO プラグインでしている SVG 最適化について、覚書を残しておきます。

## 設定ファイル（imagemin.js）
```js:imagemin.js
const keepfolder = require('imagemin-keep-folder');
const path = require('path');
const crypto = require('crypto');
const { JSDOM } = require('jsdom');
const fs = require('fs');

(async () => {
  const imageminSvgo = (await import('imagemin-svgo')).default;

  // Imageminで最初の圧縮・最適化
  await keepfolder(['src/**/*.svg'], {
    plugins: [
      imageminSvgo({
        // コード整形用プラグイン
        // js2svg: {
        //   indent: 2,
        //   pretty: true,
        //   closeSelfClosingTag: true, // 自己閉じタグを保持する
        // },
        plugins: [
          'removeDimensions', // 幅と高さを削除し、viewBox のみで制御
          'removeXMLProcInst', // XML宣言を削除
          'removeComments', // コメントを削除
          'removeMetadata', // <metadata> タグを削除
          'removeUselessDefs', // <defs> タグ内で使われていないものを削除
          'collapseGroups', // グループ要素を一つにまとめる
          'convertStyleToAttrs', // インラインのスタイルを対応するSVG属性に変換
          'sortAttrs', // 属性をソート
          'convertShapeToPath', // 図形を <path> タグに変換
          'convertColors', // 色の表現方法を最も短い形式に変換
          'convertPathData', // パスデータの圧縮
          {
            name: "removeAttrs", // 不要な属性を削除
            params: {
              attrs: 'svg:fill:none' // <svg> タグの fill="none" を削除
            }
          },
          {
            name: "inlineStyles", // <style> タグ内のスタイルをインライン化
            params: {
              onlyMatchedOnce: true,
              removeMatchedSelectors: true
            }
          },
          {
            name: "cleanupIds", // 未使用IDを削除し、短くする
            params: {
              remove: true,
              minify: true,
              preserve: [],
              preservePrefixes: [],
              force: false
            }
          },
          {
            name: "addAttributesToSVGElement", // <svg> タグに属性を追加
            params: {
              attributes: [
                {
                  'aria-hidden': 'true',
                  'data-test': 'test'
                }
              ]
            }
          },
        ]
      })
    ],
    replaceOutputDir: output => {
      return output.replace(/src\//, 'dist/')
    }
  });

  // 再帰的にディレクトリ内のすべてのSVGファイルを取得
  function getAllSvgFiles(dir) {
    let results = [];
    const list = fs.readdirSync(dir);
    list.forEach(file => {
      const filePath = path.join(dir, file);
      const stat = fs.statSync(filePath);
      if (stat && stat.isDirectory()) {
        results = results.concat(getAllSvgFiles(filePath));
      } else if (filePath.endsWith('.svg')) {
        results.push(filePath);
      }
    });
    return results;
  }

  const svgFiles = getAllSvgFiles('dist');

  // 圧縮・最適化が完了した後にプレフィックス処理を実行
  svgFiles.forEach(file => {
    const svgContent = fs.readFileSync(file, 'utf-8');
    const dom = new JSDOM(svgContent, { contentType: 'application/xml' });
    const document = dom.window.document;

    // ID名に一意のプレフィックスを追加
    const elementsWithId = document.querySelectorAll('[id]');
    elementsWithId.forEach((element) => {
      const id = element.getAttribute('id');
      if (id) {
        const prefix = crypto.createHash('md5').update(file).digest('hex').slice(0, 8);
        const newId = `${prefix}-${id}`;
        element.setAttribute('id', newId);

        // 参照している属性も更新
        const references = document.querySelectorAll(`[href="#${id}"], [xlink\:href="#${id}"], [clip-path="url(#${id})"], [fill="url(#${id})"], [filter="url(#${id})"]`);
        references.forEach(ref => {
          const attr = [...ref.attributes].find(attr => attr.value === `url(#${id})`);
          if (attr) {
            const attrName = attr.name;
            ref.setAttribute(attrName, `url(#${newId})`);
          }
        });
      }
    });

    // class名に一意のプレフィックスを追加
    const elementsWithClass = document.querySelectorAll('[class]');
    elementsWithClass.forEach((element) => {
      const classNames = element.getAttribute('class');
      if (classNames) {
        const prefix = crypto.createHash('md5').update(file).digest('hex').slice(0, 8);
        const newClassNames = classNames.split(' ').map(className => `${prefix}-${className}`).join(' ');
        element.setAttribute('class', newClassNames);
      }
    });

    fs.writeFileSync(file, document.documentElement.outerHTML, 'utf-8');
  });

})();
```

## imagemin.js の解説
### svgo プラグイン
* `removeDimensions`
SVG をレスポンシブ対応させる。
  ```diff_html
  - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" width="22" height="22">
  + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
  ```
  <br>
* `removeXMLProcInst`
XML 宣言（\<?xml ... ?\>）を削除する。<br><br>
* `removeComments`
ソースコード中のコメントを削除する。<br><br>
* `removeMetadata`
\<metadata\> タグを削除する。<br><br>
* `collapseGroups`
不要なグループを解除する。
  ```diff_html
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
  -  <g>
      <path d="...省略..."/>
      <path d="...省略..."/>
  -  </g>
  ```
  <br>
* `convertStyleToAttrs`
インラインのスタイルを対応する SVG 属性に変換する。
  ```diff_html
  - <path class="cls-1" d="...省略..." style="fill:#ccc"/>
  + <path class="cls-1" d="...省略..." fill="#ccc"/>
  ```
  <br>
* `sortAttrs`
各要素の属性をアルファベット順にソートする。<br><br>
* `convertShapeToPath`
\<circle\> タグなどの図形要素を \<path\> 要素に変換する。
  ```diff_html
  - <rect width="10" height="10"/>
  + <path d="...省略..."/>
  ```
  <br>
* `convertColors`
色表現を短い形式（例: **#ffffff** → **#fff** ）に変換する。<br><br>
* `convertPathData`
\<path\> タグなどのパスデータを最適化する。
  * 座標をより短い指定方式（相対または絶対）に変換
  * パスコマンドの変換
（例：直線のようなベジェ曲線を LineTo パスコマンドに変換）
  * 冗長なパスコマンドの削除
（例：現在の位置に移動するコマンドの削除）
  * 冗長な区切り文字・先頭のゼロの削除
  * 丸め規則を用いた数値の調整
  ```diff_html
  - <path d="M11 21C16.5228 21 21 16.5228 21 11C21 5.47715 16.5228 1 11 1C5.47715 1 1 5.47715 1 11C1 16.5228 5.47715 21 11 21ZM11 22C17.0751 22 22 17.0751 22 11C22 4.92487 17.0751 0 11 0C4.92487 0 0 4.92487 0 11C0 17.0751 4.92487 22 11 22Z"/>
  + <path d="M11 21c5.523 0 10-4.477 10-10S16.523 1 11 1 1 5.477 1 11s4.477 10 10 10m0 1c6.075 0 11-4.925 11-11S17.075 0 11 0 0 4.925 0 11s4.925 11 11 11"/>
  ```
  <br>
* `removeAttrs`
指定した属性を削除する。<br><br>
* `inlineStyles`
\<style\> タグ内のスタイルをインラインに変換する。
  ```diff_html
  - <defs>
  -   <style>
  -     .cls-1 {
  -        fill: #ccc;
  -     }
  -   </style>
  - </defs>
  - <path class="cls-1" d="...省略..."/>
  + <path class="cls-1" d="...省略..." style="fill:#ccc"/>
  ```
  <br>
* `cleanupIds`
未使用のIDを削除・IDを短縮する。<br><br>
* `addAttributesToSVGElement`
\<svg\> タグに任意の属性を追加する。

### ID・clsss にプレフィックス（接頭辞）を追加
異なる SVG 間で ID や class 名が競合すると、バグの原因となることがあります。
（例：異なるアイコンが同じスタイルを持つ、アニメーションが正しく動作しないなど）
これを避けるために、ファイルパスをもとにハッシュを生成して、プレフィックスとして追加してます。

## SVG 最適化の Before / After
よく使用するグラフィックツールで SVG を書き出し、比較しました。
追加した属性もありますが、全体的に容量が抑えられています。
### figma.svg
```html:before（1,075 バイト）
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11C22 17.0751 17.0751 22 11 22C4.92487 22 0 17.0751 0 11C0 4.92487 4.92487 0 11 0C17.0751 0 22 4.92487 22 11Z" fill="#CCCCCC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 21C16.5228 21 21 16.5228 21 11C21 5.47715 16.5228 1 11 1C5.47715 1 1 5.47715 1 11C1 16.5228 5.47715 21 11 21ZM11 22C17.0751 22 22 17.0751 22 11C22 4.92487 17.0751 0 11 0C4.92487 0 0 4.92487 0 11C0 17.0751 4.92487 22 11 22Z" fill="black"/>
<g clip-path="url(#clip0_106_5)">
<path d="M9.43 12.62C9.41 11.2 9.67 10.67 10.85 9.66C11.62 9 11.83 8.68 11.83 8.14C11.83 7.6 11.54 7.29 10.96 7.29C10.29 7.29 9.97 7.67 9.62 8.92L7 8.49C7.14 7.59 7.5 6.78 8.01 6.17C8.65 5.4 9.68 5 11 5C13.4 5 14.65 6.14 14.65 8.3C14.65 9.53 14.27 10.16 12.7 11.41C11.91 12.03 11.64 12.43 11.64 12.99V13.28H9.43V12.62ZM9.19 14.17H11.9V16.63H9.19V14.17Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_106_5">
<rect width="7.65" height="11.64" fill="white" transform="translate(7 5)"/>
</clipPath>
</defs>
</svg>
```
```html:after（821 バイト）
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" data-test="test" viewBox="0 0 22 22"><path fill="#CCC" d="M22 11c0 6.075-4.925 11-11 11S0 17.075 0 11 4.925 0 11 0s11 4.925 11 11"/><path fill="#000" fill-rule="evenodd" d="M11 21c5.523 0 10-4.477 10-10S16.523 1 11 1 1 5.477 1 11s4.477 10 10 10m0 1c6.075 0 11-4.925 11-11S17.075 0 11 0 0 4.925 0 11s4.925 11 11 11" clip-rule="evenodd"/><g clip-path="url(#16c3fd09-a)"><path fill="#000" d="M9.43 12.62c-.02-1.42.24-1.95 1.42-2.96.77-.66.98-.98.98-1.52s-.29-.85-.87-.85c-.67 0-.99.38-1.34 1.63L7 8.49c.14-.9.5-1.71 1.01-2.32C8.65 5.4 9.68 5 11 5c2.4 0 3.65 1.14 3.65 3.3 0 1.23-.38 1.86-1.95 3.11-.79.62-1.06 1.02-1.06 1.58v.29H9.43zm-.24 1.55h2.71v2.46H9.19z"/></g><defs><clipPath id="16c3fd09-a"><path fill="#fff" d="M7 5h7.65v11.64H7z"/></clipPath></defs></svg>
```
### illustrator.svg
```html:before（748 バイト）
<?xml version="1.0" encoding="UTF-8"?><svg id="_レイヤー_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><defs><style>.cls-1{fill:#ccc;}</style></defs><g id="_レイヤー_1-2"><path class="cls-1" d="M10,19.5C4.76,19.5.5,15.24.5,10S4.76.5,10,.5s9.5,4.26,9.5,9.5-4.26,9.5-9.5,9.5Z"/><path d="M10,1c4.96,0,9,4.04,9,9s-4.04,9-9,9S1,14.96,1,10,5.04,1,10,1M10,0C4.48,0,0,4.48,0,10s4.48,10,10,10,10-4.48,10-10S15.52,0,10,0h0Z"/><path d="M8.61,11.8c-.02-1.42.24-1.95,1.42-2.96.77-.66.98-.98.98-1.52s-.29-.85-.87-.85c-.67,0-.99.38-1.34,1.63l-2.62-.43c.14-.9.5-1.71,1.01-2.32.64-.77,1.67-1.17,2.99-1.17,2.4,0,3.65,1.14,3.65,3.3,0,1.23-.38,1.86-1.95,3.11-.79.62-1.06,1.02-1.06,1.58v.29h-2.21v-.66ZM8.37,13.35h2.71v2.46h-2.71v-2.46Z"/></g></svg>
```
```html:after（625 バイト）
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" data-test="test" viewBox="0 0 20 20"><path fill="#ccc" d="M10 19.5C4.76 19.5.5 15.24.5 10S4.76.5 10 .5s9.5 4.26 9.5 9.5-4.26 9.5-9.5 9.5"/><path d="M10 1c4.96 0 9 4.04 9 9s-4.04 9-9 9-9-4.04-9-9 4.04-9 9-9m0-1C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0"/><path d="M8.61 11.8c-.02-1.42.24-1.95 1.42-2.96.77-.66.98-.98.98-1.52s-.29-.85-.87-.85c-.67 0-.99.38-1.34 1.63l-2.62-.43c.14-.9.5-1.71 1.01-2.32.64-.77 1.67-1.17 2.99-1.17 2.4 0 3.65 1.14 3.65 3.3 0 1.23-.38 1.86-1.95 3.11-.79.62-1.06 1.02-1.06 1.58v.29H8.61zm-.24 1.55h2.71v2.46H8.37z"/></svg>
```
### xd.svg
```html:before（679 バイト）
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><g transform="translate(-368 -165)"><circle cx="10" cy="10" r="10" transform="translate(368 165)" fill="#ccc"/><path d="M10,1a9,9,0,1,0,9,9,9.01,9.01,0,0,0-9-9m0-1A10,10,0,1,1,0,10,10,10,0,0,1,10,0Z" transform="translate(368 165)"/><path d="M3.408-3.36H5.616v-.288c0-.56.272-.96,1.056-1.584C8.24-6.48,8.624-7.1,8.624-8.336c0-2.16-1.248-3.3-3.648-3.3a3.719,3.719,0,0,0-2.992,1.168A4.8,4.8,0,0,0,.976-8.144L3.6-7.712c.352-1.248.672-1.632,1.344-1.632a.759.759,0,0,1,.864.848c0,.544-.208.864-.976,1.52a3.084,3.084,0,0,0-1.424,2.96ZM3.168,0h2.7V-2.464h-2.7Z" transform="translate(373 181)"/></g></svg>
```
```html:after（639 バイト）
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" data-test="test" viewBox="0 0 20 20"><g transform="translate(-368 -165)"><circle cx="10" cy="10" r="10" fill="#ccc" transform="translate(368 165)"/><path d="M378 166a9 9 0 1 0 9 9 9.01 9.01 0 0 0-9-9m0-1a10 10 0 1 1-10 10 10 10 0 0 1 10-10"/><path d="M376.408 177.64h2.208v-.288c0-.56.272-.96 1.056-1.584 1.568-1.248 1.952-1.868 1.952-3.104 0-2.16-1.248-3.3-3.648-3.3a3.72 3.72 0 0 0-2.992 1.168 4.8 4.8 0 0 0-1.008 2.324l2.624.432c.352-1.248.672-1.632 1.344-1.632a.76.76 0 0 1 .864.848c0 .544-.208.864-.976 1.52a3.08 3.08 0 0 0-1.424 2.96Zm-.24 3.36h2.7v-2.464h-2.7Z"/></g></svg>
```
## Github
[ソースコード（GitHub）](https://github.com/SANPEI-design/svgo-test)