• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

sugoroku-y / svg-preview-on-code / 22729762936

05 Mar 2026 05:58PM UTC coverage: 99.132% (-0.9%) from 100.0%
22729762936

push

github

sugoroku-y
fix: パッケージ更新

233 of 237 branches covered (98.31%)

Branch coverage included in aggregate %.

1 of 1 new or added line in 1 file covered. (100.0%)

8 existing lines in 2 files now uncovered.

1138 of 1146 relevant lines covered (99.3%)

36.51 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.77
/src/SvgPreviewOnCode.ts
1
import {
5✔
2
  type DecorationOptions,
5✔
3
  type ExtensionContext,
5✔
4
  type TextDocument,
5✔
5
  type TextEditor,
5✔
6
  ColorThemeKind,
5✔
7
  MarkdownString,
5✔
8
  Range,
5✔
9
  WorkspaceConfiguration,
5✔
10
  window,
5✔
11
  workspace,
5✔
12
} from 'vscode';
5✔
13
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
5✔
14
import {
5✔
15
  isSvgPresentationAttribute,
5✔
16
  type SvgPresentationAttribute,
5✔
17
} from './SvgPresentationAttribute';
5✔
18
import { localeString } from './localeString';
5✔
19

5✔
20
/**
5✔
21
 * ユニオン型をインターセクション型に変換します。
5✔
22
 *
5✔
23
 * @example
5✔
24
 * ```ts
5✔
25
 * type T = UnionToIntersection<{a: string} | {b: number}>`;
5✔
26
 * // -> {a: string} & {b: number}
5✔
27
 */
5✔
28
type UnionToIntersection<U> = (U extends U ? (a: U) => 0 : never) extends (
5✔
29
  a: infer R,
5✔
30
) => 0
5✔
31
  ? R
5✔
32
  : never;
5✔
33
/**
5✔
34
 * 型付きのゲッターを持つ設定オブジェクトを表す型。
5✔
35
 *
5✔
36
 * WorkspaceConfigurationインターフェースを拡張し、設定プロパティへの型付きアクセスを提供します。
5✔
37
 *
5✔
38
 * @template T - 設定オブジェクトの型。
5✔
39
 */
5✔
40
type TypedConfiguration<T extends object> = Readonly<Partial<T>> &
5✔
41
  UnionToIntersection<
5✔
42
    {
5✔
43
      [K in keyof T]: {
5✔
44
        get(section: K): T[K] | undefined;
5✔
45
        get(section: K, defaultValue: T[K]): T[K];
5✔
46
      };
5✔
47
    }[keyof T]
5✔
48
  > &
5✔
49
  WorkspaceConfiguration;
5✔
50
/**
5✔
51
 * SVGプレビュー拡張機能の設定を表す型。
5✔
52
 */
5✔
53
type Configuration = TypedConfiguration<{
5✔
54
  disable: boolean;
5✔
55
  preset: Record<string, string | number>;
5✔
56
  currentColor: string;
5✔
57
  size: number;
5✔
58
}>;
5✔
59

5✔
60
export class SvgPreviewOnCode {
5✔
61
  private readonly id: string;
5✔
62
  private readonly section: string;
5✔
63
  private urlCache = new WeakMap<TextDocument, Map<string, string>>();
5✔
64
  private preset: Partial<
5✔
65
    Record<`$$${SvgPresentationAttribute}`, string | number>
5✔
66
  > = {};
5✔
67
  private size?: number;
5✔
68
  private timeout?: NodeJS.Timeout;
5✔
69

5✔
70
  private readonly parser = new XMLParser({
5✔
71
    preserveOrder: true,
5✔
72
    ignoreAttributes: false,
5✔
73
    attributeNamePrefix: '$$',
5✔
74
  });
5✔
75

5✔
76
  private readonly builder = new XMLBuilder({
5✔
77
    preserveOrder: true,
5✔
78
    suppressEmptyNode: true,
5✔
79
    ignoreAttributes: false,
5✔
80
    attributeNamePrefix: '$$',
5✔
81
  });
5✔
82
  private readonly decorationType = window.createTextEditorDecorationType({});
5✔
83

5✔
84
  /**
5✔
85
   * SvgPreviewOnCodeのインスタンスを作成します。
5✔
86
   *
5✔
87
   * SVGプレビューを構成するため、プロパティを初期化し、イベントリスナーを設定
5✔
88
   *
5✔
89
   * @param context - VS Codeから提供される拡張機能のコンテキスト
5✔
90
   */
5✔
91
  constructor(context: ExtensionContext) {
5✔
92
    // この拡張が不要になったときの後始末を設定
5✔
93
    context.subscriptions.push(this);
5✔
94
    // この拡張のID
5✔
95
    this.id = context.extension.id;
5✔
96
    // この拡張の設定上のセクション名
5✔
97
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- extension.idには必ず`.`があるのでnullにはならない
5✔
98
    [this.section] = this.id.match(/(?<=\.).*$/)!;
5✔
99

5✔
100
    // 設定などからの読み込み
5✔
101
    this.reset();
5✔
102
    // アクティブなプレビューを設定
5✔
103
    this.update(window.activeTextEditor);
5✔
104

5✔
105
    // ドキュメントを切り替え時に再設定
5✔
106
    window.onDidChangeActiveTextEditor(
5✔
107
      (editor) => {
5✔
108
        this.update(editor);
190✔
109
      },
5✔
110
      null,
5✔
111
      context.subscriptions,
5✔
112
    );
5✔
113
    // ドキュメントの変更の場合は0.5秒後に再設定
5✔
114
    workspace.onDidChangeTextDocument(
5✔
115
      (ev) => {
5✔
116
        const editor = window.activeTextEditor;
634✔
117
        if (!editor) {
634✔
118
          return;
261✔
119
        }
261✔
120
        if (ev.document !== editor.document) {
634✔
121
          return;
138✔
122
        }
138✔
123
        // 前回の変更から0.5秒たっていなければタイマーをリセット
235✔
124
        this.clearTimeout();
235✔
125
        // 0.5秒後に再設定
235✔
126
        this.timeout = setTimeout(() => {
235✔
127
          this.update(editor);
140✔
128
        }, 500);
235✔
129
      },
5✔
130
      null,
5✔
131
      context.subscriptions,
5✔
132
    );
5✔
133
    // 配色テーマ切り替えで再設定
5✔
134
    window.onDidChangeActiveColorTheme(
5✔
135
      () => {
5✔
136
        this.updateVisibleEditors();
36✔
137
      },
5✔
138
      null,
5✔
139
      context.subscriptions,
5✔
140
    );
5✔
141
    // 言語の切り替えでも再設定
5✔
142
    workspace.onDidOpenTextDocument(
5✔
143
      (document) => {
5✔
144
        // onDidOpenTextDocumentはドキュメントを開くときだけではなく言語(JavaScriptやCなど)が切り替えられるときにも呼び出される
466✔
145
        // ドキュメントを開くときにはactiveTextEditor.documentは未設定のため、それがイベントのdocumentと一致するなら言語の切り替えと見なすことができる
466✔
146
        if (window.activeTextEditor?.document === document) {
466!
UNCOV
147
          this.update(window.activeTextEditor);
×
UNCOV
148
        }
×
149
      },
5✔
150
      null,
5✔
151
      context.subscriptions,
5✔
152
    );
5✔
153
    // 設定値の変更でも再設定
5✔
154
    workspace.onDidChangeConfiguration(
5✔
155
      (e) => {
5✔
156
        if (e.affectsConfiguration(this.section)) {
81✔
157
          this.updateVisibleEditors();
40✔
158
        }
40✔
159
      },
5✔
160
      null,
5✔
161
      context.subscriptions,
5✔
162
    );
5✔
163
    // ドキュメントが閉じられたときはキャッシュを削除
5✔
164
    workspace.onDidCloseTextDocument(
5✔
165
      (document) => {
5✔
166
        this.urlCache.delete(document);
461✔
167
      },
5✔
168
      null,
5✔
169
      context.subscriptions,
5✔
170
    );
5✔
171
  }
5✔
172

5✔
173
  dispose() {
5✔
174
    for (const document of workspace.textDocuments) {
5✔
175
      this.urlCache.delete(document);
5✔
176
    }
5✔
177
    this.decorationType.dispose();
5✔
178
  }
5✔
179

5✔
180
  /**
5✔
181
   * SVGプレビュー拡張機能の設定を取得します。
5✔
182
   *
5✔
183
   * @param document - 設定を取得するテキストドキュメント。
5✔
184
   *
5✔
185
   * 省略時は、グローバル設定が使用されます。
5✔
186
   */
5✔
187
  private getConfiguration(document?: TextDocument): Configuration {
5✔
188
    return workspace.getConfiguration(this.section, document) as Configuration;
331✔
189
  }
331✔
190

5✔
191
  /**
5✔
192
   * SVGプレビュー拡張機能の設定をリセットします。
5✔
193
   *
5✔
194
   * 1. 設定値を取得
5✔
195
   * 2. URLキャッシュを初期化
5✔
196
   * 3. SVG要素のプリセット属性を設定
5✔
197
   *    - currentColorが指定されている場合はそれを使用
5✔
198
   *    - 指定されていない場合はカラーテーマに応じて白または黒を使用
5✔
199
   *    - presetが指定されている場合はそれを使用
5✔
200
   */
5✔
201
  private reset() {
5✔
202
    const { size, preset, currentColor } = this.getConfiguration();
81✔
203
    this.size = size;
81✔
204
    if ('$$color' in this.preset) {
81✔
205
      // constructorでの初期化直後はキャッシュのリセット不要(初期化直後のpresetには$$colorが含まれない)
76✔
206
      this.urlCache = new WeakMap<TextDocument, Map<string, string>>();
76✔
207
    }
76✔
208
    this.preset = {
81✔
209
      // currentColorに使用される色を指定する
81✔
210
      $$color:
81✔
211
        currentColor ||
81✔
212
        {
71✔
213
          [ColorThemeKind.Dark]: 'white',
71✔
214
          [ColorThemeKind.HighContrast]: 'white',
71✔
215
          [ColorThemeKind.Light]: 'black',
71✔
216
          [ColorThemeKind.HighContrastLight]: 'black',
71✔
217
        }[window.activeColorTheme.kind],
81✔
218
    };
81✔
219
    if (preset) {
81✔
220
      // 設定のpresetに指定されたsvgのプレゼンテーション属性をコピー
5✔
221
      for (const [name, value] of Object.entries(preset)) {
5✔
222
        if (
10✔
223
          // SVGのプレゼンテーション属性のみ受け付ける
10✔
224
          isSvgPresentationAttribute(name) &&
10✔
225
          // 値は文字列/数値のみ
10✔
226
          (typeof value === 'string' || typeof value === 'number')
10✔
227
        ) {
10✔
228
          // fast-xml-parserに指定したプリフィックスをつける
10✔
229
          this.preset[`$$${name}`] = value;
10✔
230
        }
10✔
231
      }
10✔
232
    }
5✔
233
  }
81✔
234

5✔
235
  /**
5✔
236
   * 編集時のディレイ反映用タイムアウトをクリアします。
5✔
237
   *
5✔
238
   * このメソッドは、連続して行われた編集による更新を一度だけにするために使用されます。
5✔
239
   */
5✔
240
  private clearTimeout() {
5✔
241
    if (this.timeout) {
585✔
242
      clearTimeout(this.timeout);
235✔
243
      this.timeout = undefined;
235✔
244
    }
235✔
245
  }
585✔
246

5✔
247
  /**
5✔
248
   * 指定されたテキストエディタのSVGプレビューのデコレーションを更新します。
5✔
249
   *
5✔
250
   * 1. 編集時のディレイ反映用タイムアウトをクリア
5✔
251
   * 2. 現在のドキュメントに基づいて新しいデコレーションを設定
5✔
252
   *
5✔
253
   * @param editor - 更新するテキストエディタ。未定義の場合は何も行いません。
5✔
254
   */
5✔
255
  private update(editor: TextEditor | undefined) {
5✔
256
    this.clearTimeout();
350✔
257
    if (!editor) {
350✔
258
      return;
100✔
259
    }
100✔
260
    editor.setDecorations(this.decorationType, [
250✔
261
      ...this.svgPreviewDecorations(editor.document),
250✔
262
    ]);
250✔
263
  }
250✔
264
  /**
5✔
265
   * すべての表示されているテキストエディタのSVGプレビューを更新します。
5✔
266
   *
5✔
267
   * このメソッドは、設定が変更されたときやカラーテーマが変更されたときに呼び出されます。
5✔
268
   *
5✔
269
   * 設定をリセットし、URLキャッシュをクリアし、すべての表示されているエディタのデコレーションを更新します。
5✔
270
   */
5✔
271
  private updateVisibleEditors() {
5✔
272
    const oldCache = this.urlCache;
76✔
273
    this.reset();
76✔
274
    for (const editor of window.visibleTextEditors) {
76✔
275
      if (!oldCache.has(editor.document)) {
24✔
276
        continue;
9✔
277
      }
9✔
278
      this.update(editor);
15✔
279
    }
15✔
280
  }
76✔
281

5✔
282
  private static readonly IgnoreError = {} as Error;
5✔
283

5✔
284
  /**
5✔
285
   * 与えられたドキュメント内のSVGプレビューのためのDecorationOptionsを生成する。
5✔
286
   *
5✔
287
   * このメソッドは以下の手順で処理を行う。
5✔
288
   * 1. SVG要素またはデータURLについてドキュメントを解析
5✔
289
   * 2. base64エンコードされたデータURLに変換
5✔
290
   * 3. SVGプレビューを含むホバーメッセージを持つDecorationOptionsを生成
5✔
291
   *
5✔
292
   * @param document - The text document to generate SVG preview decorations for.
5✔
293
   * @returns A generator that yields decoration options for SVG previews.
5✔
294
   */
5✔
295
  private *svgPreviewDecorations(
5✔
296
    document: TextDocument,
250✔
297
  ): Generator<DecorationOptions, void, undefined> {
250✔
298
    if (this.getConfiguration(document).disable) {
250!
UNCOV
299
      return;
×
UNCOV
300
    }
×
301
    const text = document.getText();
250✔
302
    if (!text) {
250✔
303
      return;
105✔
304
    }
105✔
305
    const previousMap =
145✔
306
      this.urlCache.get(document) ?? new Map<string, string>();
250✔
307
    const nextMap = new Map<string, string>();
250✔
308
    // 新規で追加されるものがあるか
250✔
309
    let comingNew = false;
250✔
310
    // SVG要素とDataスキームURIを抽出する正規表現
250✔
311
    const svgRegex =
250✔
312
      /(<svg\s[^>]*>.*?<\/svg>)|\bdata:image\/\w+(?:\+\w+)?;base64,[A-Za-z0-9+/]+=*/gs;
250✔
313

250✔
314
    // 文書テキスト中のSVG要素とDataスキームURIを順次処理する
250✔
315
    for (const { index, 0: match, 1: svg } of text.matchAll(svgRegex)) {
250✔
316
      // タグ前後の空白を除去したものをキャッシュのキーにする(dataスキームはキャッシュ対象外)
250✔
317
      const normalized = svg?.replace(/(?<=>)\s+|\s+(?=<)/g, '');
250✔
318
      try {
250✔
319
        const url = (() => {
250✔
320
          // dataスキームはそのまま使用
250✔
321
          if (!normalized) {
250✔
322
            return match;
15✔
323
          }
15✔
324
          // キャッシュにあればそちらを使う
235✔
325
          const cached = previousMap.get(normalized);
235✔
326
          if (cached) {
250✔
327
            if (cached === 'error') {
110✔
328
              // 前回何らかの問題があったものは最初からエラーにする
70✔
329
              throw SvgPreviewOnCode.IgnoreError;
70✔
330
            }
70✔
331
            nextMap.set(normalized, cached);
40✔
332
            return cached;
40✔
333
          }
40✔
334
          // 無ければsvgをparse
125✔
335
          const svg = (() => {
125✔
336
            try {
125✔
337
              return this.parser.parse(normalized) as [
125✔
338
                {
125✔
339
                  ':@'?: Record<`$$${string}`, string | number>;
125✔
340
                  svg: unknown[];
125✔
341
                },
125✔
342
              ];
125✔
343
            } catch {
125✔
344
              // 編集途中のSVGは普通に解析エラーが発生するため無視する例外として投げなおす
10✔
345
              throw SvgPreviewOnCode.IgnoreError;
10✔
346
            }
10✔
347
          })();
125✔
348
          // ルートsvg要素の属性だけを操作する
125✔
349
          const svgAttributes = svg[0][':@'];
125✔
350
          if (svgAttributes?.$$xmlns !== 'http://www.w3.org/2000/svg') {
250✔
351
            // 名前空間がSVGのものでなければ無視する
10✔
352
            throw SvgPreviewOnCode.IgnoreError;
10✔
353
          }
10✔
354
          let size: { $$width: number; $$height: number } | undefined;
105✔
355
          if (this.size) {
250✔
356
            const width = Number(svgAttributes.$$width) || undefined;
35✔
357
            const height = Number(svgAttributes.$$height) || undefined;
35✔
358
            size =
35✔
359
              !width || !height
35✔
360
                ? { $$width: this.size, $$height: this.size }
35✔
361
                : width < height
35✔
362
                  ? {
10✔
363
                      $$width: (width / height) * this.size,
5✔
364
                      $$height: this.size,
5✔
365
                    }
5✔
366
                  : {
10✔
367
                      $$width: this.size,
5✔
368
                      $$height: (height / width) * this.size,
5✔
369
                    };
5✔
370
          }
35✔
371

105✔
372
          svg[0][':@'] = {
105✔
373
            // svg要素に属性を追加
105✔
374
            ...this.preset,
105✔
375
            // 元々指定されている属性が優先
105✔
376
            ...svgAttributes,
105✔
377
            // sizeに合わせて調整した幅と高さを優先
105✔
378
            ...size,
105✔
379
          };
105✔
380
          // Base64エンコードしてDataスキームURIにする
105✔
381
          const newUrl = `data:image/svg+xml;base64,${Buffer.from(
105✔
382
            this.builder.build(svg),
105✔
383
          ).toString('base64')}`;
105✔
384
          // 生成した画像URLはキャッシュしておく
105✔
385
          nextMap.set(normalized, newUrl);
105✔
386
          comingNew = true;
105✔
387
          return newUrl;
105✔
388
        })();
250✔
389
        // svgもしくはDataスキームURIの範囲にプレビューを追加
250✔
390
        const start = document.positionAt(index);
250✔
391
        const end = document.positionAt(index + match.length);
250✔
392
        const range = new Range(start, end);
250✔
393
        // Markdown文字列として追加
250✔
394
        const hoverMessage = [
250✔
395
          new MarkdownString(
250✔
396
            `### ${normalized ? 'SVG' : 'Data URL'} ${localeString('preview.preview')}`,
250✔
397
          ),
250✔
398
          new MarkdownString(`![](${url})`),
250✔
399
        ];
250✔
400
        if (normalized) {
250✔
401
          // 設定のリンクはsvgのときだけ
145✔
402
          const link = new MarkdownString(
145✔
403
            `[$(gear) ${localeString('preview.settings')}](command:workbench.action.openSettings?["@ext:${this.id}"])`,
145✔
404
            true,
145✔
405
          );
145✔
406
          link.isTrusted = {
145✔
407
            enabledCommands: ['workbench.action.openSettings'],
145✔
408
          };
145✔
409
          hoverMessage.push(link);
145✔
410
        }
145✔
411
        yield { range, hoverMessage };
160✔
412
      } catch (ex) {
250✔
413
        if (normalized) {
90✔
414
          // 生成失敗したこともキャッシュする
90✔
415
          nextMap.set(normalized, 'error');
90✔
416
          if (!previousMap.has(normalized)) {
90✔
417
            comingNew = true;
20✔
418
          }
20✔
419
        }
90✔
420
        /* c8 ignore next 4 IgnoreError以外の例外をテストで発生させられないのでカバレッジ計測からは除外 */
5✔
421
        if (ex !== SvgPreviewOnCode.IgnoreError) {
5✔
422
          // 無視するエラーでなくてもログに出すだけ
5✔
423
          console.error('Failed to generate SVG preview:', ex);
5✔
424
        }
5✔
425
      }
90✔
426
    }
250✔
427
    if (nextMap.size) {
250✔
428
      if (comingNew || nextMap.size !== previousMap.size) {
125✔
429
        // 新規で追加、もしくは数が変わった、つまり変化があったときだけ更新
125✔
430
        this.urlCache.set(document, nextMap);
125✔
431
      }
125✔
432
    } else if (previousMap.size) {
250✔
433
      // もともとあったのに、ひとつも無くなったら削除
5✔
434
      this.urlCache.delete(document);
5✔
435
    }
5✔
436
  }
250✔
437
}
5✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc