🎨

カラーテーマによるSVGの描画の変化をチェックできるツールを作ってみた

2024/09/06に公開

SVG内のスタイル指定においても prefers-color-scheme が使え、それを利用してライトテーマ・ダークテーマごとに配色が異なる favicon を一つのSVGファイルで実現できることは既に知られているかと思います。

favicon 以外でも、GitHubのREADMEページにロゴとなるSVGを配置し、各テーマに対応している例も見かけます。

しかし各カラーテーマ下のSVGの描画変化を一画面で確認・比較できる方法が見つかりませんでした。Vue と DOM のおさらいも兼ねて確認・比較できるツールページを作成してみました。

作ったもの

https://nanase.cc/tools/svg/theme-checker

実現までの道のり

まず、どのようなツールを作りたいかを決めました。

  • SVGがライトテーマ・ダークテーマの2画面でプレビューできる
    • マウスのドラッグで移動、ホイールで拡大と縮小、それが2画面で同期されたら最高
  • SVGの内容が表示できて、編集するとプレビュー画面に即時に反映される

これらを実現するために、いくつかの壁を解決する必要がありました。

  • :root 指定があるSVGを複数配置できるか
  • prefers-color-scheme の設定は上書きできるか

ひとつずつ見ていきます。

:root 指定があるSVGを複数配置できるか

:root はその ドキュメント(文書) を表すツリーのルート要素を選択する擬似クラスです。

通常のHTMLで :root を指定すると、それはHTMLのルート要素、つまり <html> を選択することになります。同様に、SVGファイル内での :root とは、つまりルート要素である <svg> を選択することになります。

では、このSVGファイルをそのままHTMLに埋め込むとどうなるでしょうか?

SVGをHTMLに埋め込むことでSVGはHTMLと同じ ドキュメント に所属することになります。つまり、<svg> の内部にある :root が選択するのは <svg> ではなく <html> ということになります。

これの何が問題であるかというと、今回やりたいことのように同一ソースの複数のSVGを配置した場合です。複数のSVGであっても :root<html> を選択するので、最後に存在するSVGのスタイルだけが有効になってしまいます。

実際のところ、問題は :root だけではありません。ID名やクラス指定でさえも <html> が所属する ドキュメント と混ざることになるので、ID名やクラスの衝突が起こり得ます。今回は同一ソースの複数SVGを表示させるため、ID名やクラスを使っていると必ず衝突します。衝突はすなわち、SVGに指定したスタイルが意図したとおりに表示されないということになります。

では、これら :root や ID名、クラスが指し示すルート要素を別にするにはどうしたらよいでしょうか?

解決方法: ドキュメントを分ける

最も簡単なアプローチとしては、SVGが所属するドキュメントを、HTMLとは別にしてしまえばよいです。

実のところ、<object> で埋め込んだ外部のSVG画像は自動でドキュメントをが生成され、ルート要素は <svg> となります。同じことは <iframe> でもできますが、その場合のルート要素は別のドキュメントの <html> となり、やはり元の <html> とは別要素となるので同じく問題を解決できます。


<object> で埋め込まれたSVG画像にドキュメントが生成されている様子

ドキュメントの作成は document.implementation.createDocument() で行えます。今回の場合は分離したドキュメントの下の <svg> の表示制御を詳細にやりたいため、<iframe> を使ってドキュメントの分離を行いました。

prefers-color-scheme の設定は上書きできるか

結論から言うと、(まだ)できません。
Stackoverflow の回答にあるとおり、JavaScript 経由でこれを変更することは現状ではできません。

CSS の color-scheme を使うと <img> を使ってSVG画像を埋め込んだ場合はカラーテーマを変更することができます。ところが <iframe><object> で埋め込んだSVGについては color-scheme が適用されません。

次の画像は prefers-color-scheme: light の条件下で <iframe> を使い、その内部の <html> 要素に color-scheme: dark を指定して表示される結果を表したものです。<html> の子孫の <svg> にも color-scheme: dark が継承されてスタイルが正しくセットされていますが、描画には反映されていません。プレビューのSVGにはどちらもライトテーマが適用されており、セットされたはずのダークテーマ用のスタイルが適用されていません。


(失敗例) Chrome 上で color-scheme を使ったとき

今回はSVGの内容をDOMとして持ちたいので、<iframe><object> を使う必要があります。そのため <img> および color-scheme は使えません。それでもSVGのカラーテーマの変更を実現するためには、color-scheme および prefers-color-scheme を用いる以外の方法を用いる必要があります。

上記の画像でも使用していますが、Chrome の DevTools や Firefox の Web開発ツールには prefers-color-scheme を即席で上書きできるツールが用意されています。将来的には JavaScript 側から(部分的に)上書きできるのかもしれませんが、現状ではやはりページ全体のテーマを変えるためのものです。


Chrome の DevTools にて

解決方法: prefers-color-scheme の記述そのものを置換

やや強引な方法になってしまいますが、SVGの内部で記述されているスタイルの @media (prefers-color-scheme: dark) そのものを置換して、強制的にカラーテーマを適用するようにしました。

たとえば以下のようなスタイル指定があったとき、

:root {
  --color-primary: #69c5ff;
  --color-secondary: #425beb;
}
@media (prefers-color-scheme: dark) {
  :root {
    --color-primary: #fff;
    --color-secondary: #a3a3a3;
  }
}

以下のように置換を行うことでカラーテーマが適用されるようにします。

svg {
  --color-primary: #69c5ff;
  --color-secondary: #425beb;
}
.svg-inner-theme-dark {
  svg {
    --color-primary: #fff;
    --color-secondary: #a3a3a3;
  }
}

:rootsvg に、 @media (prefers-color-scheme: dark).svg-inner-theme-dark に置き換えました。ライトテーマ向けにも同様の置換を行います。

前述のとおり、SVGは <iframe> に所属するドキュメントの更に内部の <html> に配置されるので、置換後のスタイルがうまく適用できます。

具体的な構造としては以下のようになっています。

<iframe class="preview preview-theme-dark">
  #document
    <html style="cursor: grab;">
      <body class="svg-inner-theme-dark" style="margin: 0px; overflow: hidden; transform-origin: left top; transform: translate(0px, 0px) scale(1);">
        <svg>
          ...
        </svg>
      </body>
    </html>
</iframe>

コンテナとなる要素に <iframe> を使う理由は、内部の <html> および <body> に対してスタイルとクラス名を指定したいためです。今回は <html> でマウスカーソルを、<body> でカラーテーマ適用のためのクラス名と移動・拡大と縮小のための transform を指定しています。

実装

サイト自体は Vue + Vuetify を使っているので、構築はそれに合わせました。

エディター部分は Prism code editor を使いました。SVG専用の言語設定がありませんが、XMLの設定でも問題ないはずです。

ユーザが入力したSVGデータを表示させるため、入力をそのまま表示されるのは危険です。今回は DOMPurify を使ってSVGにおいて記述可能な危険なコードを除去することにしました。

ThemeCheckerApp.vue

ページの外枠となる ThemeCheckerApp.vue のテンプレートは以下のようにしました。

ThemeCheckerApp.vue
<template>
  <v-container>
    <v-row no-gutters>
      <v-col cols="6" class="text-center">
        <!-- ライトテーマ用SVGプレビューコンポーネント -->
        <SVGPreviewer ref="lightPreviewer" theme="light" :svgElement />
      </v-col>
      <v-col cols="6" class="text-center">
        <!-- ライトテーマ用SVGプレビューコンポーネント -->
        <SVGPreviewer ref="lightPreviewer" theme="dark" :svgElement />
      </v-col>
    </v-row>
    <v-row no-gutters class="mt-1">
      <v-col cols="12">
        <div ref="editorElement"></div>
      </v-col>
    </v-row>
  </v-container>
</template>

SVGPreviewer コンポーネントで内部の <iframe> の操作やスタイルの書き換えを行います。以下のようにスクリプト部分を書きます。

ThemeCheckerApp.vue のスクリプト(長いのでクリックで表示)
ThemeCheckerApp.vue
<script setup lang="ts">
import { ref, watch, computed, onMounted, nextTick } from 'vue';
import { useTheme } from 'vuetify';
import { basicEditor, updateTheme } from 'prism-code-editor/setups';
import { loadTheme } from 'prism-code-editor/themes';
import 'prism-code-editor/prism/languages/xml';
import DOMPurify from 'dompurify';

import type { PrismEditor } from 'prism-code-editor';
import svgExample from '@/../public/svg/example.svg?raw';
import SVGPreviewer from './SVGPreviewer.vue';

const theme = useTheme();
let editor: PrismEditor | null = null;
const lightPreviewer = ref<InstanceType<typeof SVGPreviewer>>();
const darkPreviewer = ref<InstanceType<typeof SVGPreviewer>>();
const editorElement = ref<HTMLElement | null>(null);
const editorTheme = computed<string>(() => (theme.global.current.value.dark ? 'github-dark' : 'github-light'));
const svgElement = ref<SVGSVGElement>();

onMounted(() => {
  if (editorElement.value) {
    editor = basicEditor(
      editorElement.value,
      {
        language: 'xml',
        theme: editorTheme.value,
        value: svgExample,
      },
      () => {
        // エディターが初期化された後に行う処理
        editor!.scrollContainer.style.height = '300px';
        onUpdateEditor.call(editor!, svgExample);
        editor!.addListener('update', onUpdateEditor);
      },
    );
  }

  // 危険な xlink:href は除去するようフック追加
  DOMPurify.addHook('afterSanitizeAttributes', function (node) {
    if (node.hasAttribute('xlink:href') && !node.getAttribute('xlink:href')?.match(/^#/)) {
      node.remove();
    }
  });
});

watch(
  () => editorTheme.value,
  async () => {
    if (editor) {
      // ページのテーマ切り替えボタンを押したらエディターのテーマも切り替え
      // (SVGのテーマとは無関係)
      await loadTheme(editorTheme.value);
      updateTheme(editor, editorTheme.value);
    }
  },
);

function onUpdateEditor(this: PrismEditor, value: string) {
  // 危険なコードをここで全て除去
  const cleanElement = DOMPurify.sanitize(value, { RETURN_DOM: true, ADD_TAGS: ['use'] });

  if (cleanElement.hasChildNodes() && isSVGSVGElement(cleanElement.childNodes[0])) {
    svgElement.value = cleanElement.childNodes[0];
  }
}

function isSVGSVGElement(node: Node): node is SVGSVGElement {
  return node.nodeName === 'svg';
}
</script>

SVGPreviewer.vue

SVGのプレビューを行うコンポーネントになります。テンプレートは非常に簡素で、<iframe> がひとつあるだけです。

SVGPreviewer.vue
<template>
  <iframe ref="previewElement" class="preview" :class="`preview-theme-${theme}`"></iframe>
</template>

prop の svgElement で親コンポーネントからSVGのデータが渡されるので、それをクローンして <iframe> 内のドキュメントの更に内部の <body> 直下に追加しています。

SVGPreviewer.vue のスクリプト(長いのでクリックで表示)
SVGPreviewer.vue
<script setup lang="ts">
import { ref, watch } from 'vue';

const { theme, svgElement, scale } = defineProps<{
  theme: 'light' | 'dark';
  svgElement?: SVGSVGElement;
}>();
const previewElement = ref<HTMLIFrameElement | null>(null);

watch(
  () => svgElement,
  () => {
    if (svgElement && previewElement.value && previewElement.value.contentDocument) {
      const newNode = svgElement.cloneNode(true) as SVGSVGElement;
      updateSvgStyles(newNode);
      insertSVGIntoIframe(newNode, previewElement.value, theme);
    }
  },
);

function insertSVGIntoIframe(svgSvgElement: SVGSVGElement, iframe: HTMLIFrameElement, theme: 'dark' | 'light') {
  const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;

  if (!iframeDoc) {
    console.error('Failed to access iframe document.');
    return;
  }

  // iframe内部のdocumentを一旦クリア
  iframeDoc.open();
  iframeDoc.close();
  iframeDoc.body.appendChild(svgSvgElement);
  iframeDoc.body.style.margin = '0';
  iframeDoc.body.style.overflow = 'hidden';
  iframeDoc.body.parentElement!.style.cursor = 'grab';
  iframeDoc.body.classList.add(`svg-inner-theme-${theme}`);
}

function updateSvgStyles(svgElement: SVGSVGElement): void {
  const styleElement = svgElement.querySelector('style');

  if (!styleElement || !styleElement.textContent) {
    return;
  }

  let styleText = styleElement.textContent;

  styleText = styleText.replace(
    /@media\s*\(\s*prefers-color-scheme\s*:\s*dark\s*\)\s*\{([^}]+)\}/g,
    '.svg-inner-theme-dark {$1}',
  );
  styleText = styleText.replace(
    /@media\s*\(\s*prefers-color-scheme\s*:\s*light\s*\)\s*\{([^}]+)\}/g,
    '.svg-inner-theme-light {$1}',
  );
  styleText = styleText.replace(/:root/g, 'svg');

  const newStyleElement = document.createElement('style');
  newStyleElement.textContent = styleText;

  svgElement.replaceChild(newStyleElement, styleElement);
}
</script>

ここまで示したコードでSVGは表示できますが、実際にはマウスのドラッグ移動とホイールによる拡大と縮小の処理が入ります。完全なコードは以下からどうぞ。

https://github.com/nanase/tools/tree/main/src/svg

今後の課題

やりたい最低限のことは実現できているので、今後は味付け程度に便利機能をつけていきたいと思います。

  • 各種の警告表示
    • prefers-color-scheme がない場合
    • SVGとして解釈できない場合
  • SVGファイルのオープン
    • ドラッグ&ドロップ
    • ファイルダイアログでローカルファイルから選択
  • SVGの表示領域の拡張、背景色の変更
  • タッチデバイス対応
    • Vuetifyを使っているのでページそのものはタッチデバイスに対応していますが、SVGのプレビューコンポーネントは自作のため未対応です

Discussion