LoginSignup
19
18

More than 3 years have passed since last update.

Chrome でなるべく早くスクリーンキャプチャを撮る拡張機能を作りましたが想像以上に大変でした

Last updated at Posted at 2019-08-21

ついこの前、Chrome でスクリーンキャプチャをできる限り素早く取得する拡張機能を作成しました。

Immediate Shot サムネイル

Immediate Shot

ある日、会社でディレクターから Chrome で手軽にスクリーンキャプチャを撮る方法はないかと尋ねられ、確か、最近の Chrome だったら……と、開発者ツールを開いてからキャプチャを撮る方法を提案しました。

しかし、ショートカットがあるならまだしも、画面ごとに F12 キーを押して、詳細機能を呼び出し……なんてやるのは、大量のキャプチャを撮らねばならないときは結構な手間です。

聞けば、ちょっと前までは Firefox にワンクリックで画面のキャプチャを撮ってくれる拡張機能があったということだったのですが、Firefox のアップデートによりその拡張機能は使えなくなり、現在 Chrome に公開されている拡張機能も試したものは全て何かしらのワンクッションが挟まってるとのことでした。

なるほど、それだったら趣味も兼ねて Chrome の拡張機能開発に挑戦してみよう、と思い至り、開発に着手しました。

なぁに、拡張機能独自のお約束を理解するのに手間取っても、30分もあればおちゃのこさいさい、Chrome ごと木っ端微塵にしてくれるわ、と。

え、意外とこれ難しくない?

というわけで、この記事はサイト全体のキャプチャを撮るのが意外と面倒だったのでその方法を Chrome の拡張機能として作る方法の共有となっております。

拙い拡張機能ではありますが、Chrome の拡張機能作成の流れを流し読みする目的にもどうぞ。

なお、コードは TypeScript によって開発しましたので、この記事の説明も TypeScript で行います。途中まで Babel で作成してたんですが、よく考えたら Chrome でしか動かさないよねこれ??と気づいたので書き直しました。型も重要ですけど、皆さんも着手前の要件定義・技術選定はしっかりと行いましょう。くわばらくわばら。

先に拡張機能の紹介

まだ改修の余地はありますが、現在実際に動くコードは GitHub に公開しています。

https://github.com/Go-Noji/Immediate-Shot

拡張機能をインストールすると Chrome のアドレスバー横に拡張機能のアイコンが表示されるのですが、このアイコンをクリックするとアクティブなタブのキャプチャが可能な限り速やかに保存されます。

右クリックして選択可能な「オプション」にて拡張機能の設定が可能で、キャプチャ範囲の選択肢として、

  • サイト全体をほぼ完璧にキャプチャする全て(高品質)モード
  • 低画質な代わりにサイト全体を一瞬でキャプチャする全て(高速)モード
  • 現在表示している範囲のみキャプチャする表示中モード

が選択可能です。

更に、ダウンロードされる png ファイルの名前も設定可能で、任意の文字列以外に

  • {{title}}: ページタイトルに変換
  • {{url}}: URLに変換
  • {{counter}}: 連番に変換

という自動で展開される変数も用意してあります。

ボタンが押されてから画像をダウンロードするまでの流れ

本題に入りましょう。
アプリでは拡張機能のアイコンを押す or 右クリックでメニューを呼び出すときにキャプチャが開始されますが、その間に発生する処理の流れは以下の通りです。

  1. 必要な設定を読み込む
  2. 現在表示しているサイトの表示サイズ・全体サイズを算出する
  3. 必要な回数、画面をスクロールしながらキャプチャを行い、画像データを取り溜める
  4. ダウンロードする画像と同じサイズの canvas 要素を生成し、画像を並べる
  5. canvas の toDataURL() を用い、画像を吐き出す
  6. 設定に基づきファイル名を決定、ダウンロードする

何気なく書いてますが、API を多用する関係上幾度となく非同期処理が挟まるため、コードは実質 Promise 祭りです。

また、この流れ以外にも拡張機能の「オプション」を設定・保存するための処理も必要です。

そもそも、今回の拡張機能を作成するにはどのような設計が必要なのか

設計と言葉を濁しましたが、まず最初に Chrome の拡張にはどんなことをする「場」があって、その各「場」に対応するファイルはなんなのかという説明をしたいと思います。

まず、Chrome に対する指示書たる manifest.json について理解しましょう。

詳しくは以下の記事に解説が載っています。僕もこれを見ながら開発しました。

Chrome 拡張機能のマニフェストファイルの書き方

詳細は上記で見てもらうとして、ここで取り上げたい項目が三つあります。

manifest.json
{
    "background": {
        "persistent": false,
        "scripts": ["background.bundle.js"]
    },
    "content_scripts": [{
        "js": ["page.bundle.js"],
        "matches": ["http://*/*", "https://*/*"]
    }],
    "options_ui": {
        "page": "options.html",
        "chrome_style": true
    },
}

上記は実際に作った拡張機能の抜粋になりますが、この三つの項目が先述の「場」に対応する設定となります。

そもそも、拡張機能は JavaScript を動かすことで実行することになるのですが、その JavaScript を動かせる場として

  • バックグラウンド
  • 閲覧中の Web サイト
  • 拡張機能の設定画面

の三種類があり、それぞれが上記コードの

  • background
  • content_scripts
  • options_ui

に対応しています。

それぞれできることが違うのですが、今回の拡張機能では

  • バックグラウンド → 実行のトリガー・キャプチャの実行・ダウンロード
  • 閲覧中の Web サイト → css・スクロール制御
  • 拡張機能の設定画面 → 設定の表示・更新

を行うため、各種 JavaScript を用意します。

設定を保存するための画面を用意する

文言を国際化する

設定画面は自分でデザインすることもできますが、css を用意しなくても Chrome が簡易なスタイルを用意してくれているのでそちらを利用することにします。

manifest.json
{
  ~略~
  "options_ui": {
    "page": "options.html",
    "chrome_style": true
  },
  ~略~
}

上記 json が示している通り、拡張機能のオプション画面が options.html となります。

普通の Web サイトを作成するように作成して問題ないのですが、ここでひと手間掛けたいのが文言の国際化です。

この拡張機能のオプション設定画面は日本語環境では日本語、それ以外では英語で文言を表示するようにしています。

拡張機能の国際化には chrome.i18n という API を使用しますが、残念ながらリンク先は英語一本です。エンジニアたるもの言語に屈してはならないのです

屈した三流園児ニアの僕は以下の記事と Google 翻訳を駆使して開発に臨みました。
Google さんに怒りをぶつけようとしても Google 翻訳というありがたいサービスの前にはただただひれ伏すのみです。
Chromeエクステンションを作ろう:国際化編

_locals/ja/message.json
{
  "msg_desc": {"message": "Immediate Shot はワンクリックで素早くスクリーンショットを撮る Chrome 用拡張機能です。"},
  "msg_saved": {"message": " 設定を保存しました。"},
  "msg_option_title_captureRange": {"message": "範囲"},
  "msg_option_description_full": {"message": "全て(高速)"},
  "msg_option_description_display": {"message": "表示中"},
  "msg_option_description_perfect": {"message": "全て(高品質)"},
  "msg_option_title_fileName": {"message": "ファイル名"},
  "msg_option_title_templates": {"message": "ファイル名変数"},
  "msg_option_description_title": {"message": "ページタイトルに置き換わります"},
  "msg_option_description_url": {"message": "URLに置き換わります"},
  "msg_option_description_counter": {"message": "下記'count'に置き換わります(数値は一つずつ増えていきます)"},
  "msg_option_title_counter": {"message": "カウンター(ファイル名にて{{counter}}として使用)"},
  "msg_option_max": {"message": "チェックするとWebサイトの最大幅を全要素から検索するようになります(全画面スクリーンショットが上手くいかない場合にお試しください)"},
  "msg_option_save": {"message": "保存"}
}
options.html
<!--略-->
  <li>
    <spna><span class="lang" data-key="option_title_captureRange"></span>: </spna>
    <label><input id="perfect" name="range" type="radio" value="perfect"><span class="lang" data-key="option_description_perfect"></span></label>
    <label><input id="full" name="range" type="radio" value="full"><span class="lang" data-key="option_description_full"></span>&nbsp;</label>
    <label><input id="display" name="range" type="radio" value="display"><span class="lang" data-key="option_description_display"></span>&nbsp;</label>
  </li>
<!--略-->
options.ts
  //~略~

  /**
   * lang クラスを持った要素の 'msg_' + data-key属性 から言語メッセージを取得し、要素のテキストを変更する
   */
  const setLang = () => {
    //テキスト変換対象の取得
    const targets = document.getElementsByClassName('lang');

    //変換処理
    for (let i = 0, max = targets.length; i < max; i = (i + 1) | 0) {
      //対象を一旦変数へ挿入
      const target = targets.item(i);

      //対象が存在しなかったらなにもしない
      if (target === null) {
        continue;
      }

      //メッセージキーを一旦変数へ挿入
      const key = target.getAttribute('data-key');

      //メッセージキーが存在しなかったらなにもしない
      if (key === null) {
        continue;
      }

      //テキスト設定
      target.innerHTML = chrome.i18n.getMessage('msg_'+key);
    }
  };

  //~略~

  //言語ごとにテキスト設定
  document.addEventListener('DOMContentLoaded', setLang);

上記のように、_locals/iso 639 言語コード/message.json を用意しておくと chrome.i18n.getMessage で自動判別された言語のメッセージを取得できるので、この拡張機能では class 属性に lang を持つ要素の data-key 属性で翻訳を行うというアプローチをとっています。

設定の取り出し・保存

設定画面のスクリーンショット

設定値は chrome.storage API を通じて取得・更新します。

まずは取得ですが、まだ値を保存していないキーに対しての初期値が必要です。
保存は設定画面でしか行いませんが、取得は拡張機能の至る所で行うので、設定だけ保存しておく TypeScript ファイルを用意しておきます。

config.ts
//キャプチャ範囲初期値
export const DEFAULT_RANGE: string = 'perfect';

//タイトル名初期値
export const DEFAULT_TITLE: string = '{{title}}';

//サイトのマックス値を画面幅だけで取るか、全要素から取得するか
export const DEFAULT_MAX: boolean = false;

//カウント変数初期値
export const DEFAULT_COUNTER: number = 1;

/*----以下二つは関係なし----*/

//複数枚キャプチャの際、次のキャプチャまで何ミリ秒間隔を置くか
export const CAPTURE_WAIT_MILLISECONDS: number = 20;

//CAPTURE_WAIT_MILLISECONDS が使われる際、最初の一回だけはこの値が使用される
export const FIRST_CAPTURE_WAIT_MILLISECONDS: number = 100;

実際に使用する際には、

options.ts
import {DEFAULT_COUNTER, DEFAULT_MAX, DEFAULT_RANGE, DEFAULT_TITLE} from "./config";

  //~略~

  /**
   * 読み取り
   */
  const restore_options = () => {
    chrome.storage.sync.get({
      range: DEFAULT_RANGE,
      title: DEFAULT_TITLE,
      counter: DEFAULT_COUNTER,
      max: DEFAULT_MAX
    }, (items: {[key: string]: string}) => {
      setCheckedRadio(items.range);
      setValue('title', items.title);
      setValue('counter', String(items.counter));
      setCheckedCheckbox('max', Boolean(items.max));
    });

  //~略~

  document.addEventListener('DOMContentLoaded', restore_options);

  //~略~

のように、chrome.storage.sync.get() の第二引数がコールバックになっているので、ここでは item 引数に入ってきた設定値を各要素に描写する関数へ渡しています。

保存する際は逆に各要素から value を読み取り、 chrome.storage.sync.set() で保存を行います。

options.ts
import {DEFAULT_COUNTER, DEFAULT_MAX, DEFAULT_RANGE, DEFAULT_TITLE} from "./config";

  //~略~

  /**
   * 保存
   */
  const save_options = () => {
    //設定の取得(range)
    let range = 'full';
    if (isCheckedRadio('display')) {
      range = 'display';
    }
    else if (isCheckedRadio('perfect')) {
      range = 'perfect';
    }

    //設定の取得(title)
    const title = getValue('title');

    //設定の取得(counter)
    const counter = Number(getValue('counter'));

    //設定の取得(max)
    const max = isCheckedCheckbox('max');

    //保存
    chrome.storage.sync.set({range, title, counter, max}, () => {
      const status = document.getElementById('status');
      if (status === null) {
        return;
      }
      status.textContent = chrome.i18n.getMessage('msg_saved');
      setTimeout(() => {
        status.textContent = '';
      }, 750);
    });
  };

  //~略~

  const save = document.getElementById('save');
  if (save !== null) {
    save.addEventListener('click', save_options);
  }

  //~略~

chrome.storage.sync.set() にも第二引数にコールバックが仕込めるので設定の保存が完了した表示を出すのに利用しています。

スクリーンキャプチャを撮るための前提

仮に、現在写っている画面のみを撮るのであれば

この拡張機能の肝となるキャプチャは「現在写っている部分のみ」ならば以下のようなコードで実現可能です。

manifest.json
{
  ~略~
  "permissions": ["tabs", "downloads"],
  ~略~
}
background.ts
const action = () => {
  chrome.tabs.captureVisibleTab((url) => {
    chrome.downloads.download({url: url, filename: 'capture.png');
  });
};

tabs を許可すると画面キャプチャを base64 データとして取得できる chrome.tabs.captureVisibleTab が、downloads を許可すると直接ファイルをダウンロードできる chrome.downloads.download が使用できます。

後々重要になってくる制約ですが、chrome.tabs.captureVisibleTab は先述したバックグラウンドでのみ使用できる API です。

アドレスバー横のアイコンをクリックした際なら、

background.ts
chrome.browserAction.onClicked.addListener(action);

右クリックメニューに項目を用意するのであれば、

manifest.json
{
  ~略~
  "permissions": ["contextMenus"],
  ~略~
}
background.ts
//メニューの登録
chrome.contextMenus.create({
  id: 'run',
  title: 'Immediate Shot',
  contexts: ['all'],
  type: 'normal'
});

//クリックされたとき
chrome.contextMenus.onClicked.addListener(action);

のようにバックグラウンド用の JavaScript を用意すれば目的は達成できるでしょう。
これらトリガー設定もバックグラウンドのみが行える仕事です。

全面キャプチャを撮るためには画面をスクロールしながらキャプチャを断片的に撮り溜める必要がある

しかし、残念ながら chrome.tabs.captureVisibleTab に対してサイトの全面キャプチャを撮る chrome.tabs.captureFullVisibleTab 的な API は今のところ実装されていません。

それなのに世のキャプチャ系拡張機能はどうやって全面キャプチャを撮っているのかというと、

  1. サイトの一番左上までスクロールする
  2. 現在表示されている範囲をキャプチャする
  3. 表示されていない範囲を撮るために右 or 下へスクロール
  4. 横スクロール&縦スクロールできる範囲を撮りつくすまで 2 → 3 を繰り返す
  5. 取り溜めた各画像を HTML Canvas 上に再度並べ直し、合成して一枚の画像データとして吐き出す
  6. データをダウンロード

という涙ぐましい努力を行うことで全面キャプチャを実現しています。

ロジックを組むだけで眩暈がしそうですが、真に骨が折れるのは上記 2 と 3 の間が非同期処理でやり取りされるという点です。

  • background
  • content_scripts
  • options_ui

この中で唯一出番のなかった content_scripts はここで出てきます。

先述したように、キャプチャを撮る、ボタンクリックをトリガーする等の仕事は background でしかできません。

対して閲覧中の Web サイトに直接働きかける、つまり

  • 要素の情報を取得する(座標やボックスモデル)
  • 要素のスタイルを変更する
  • 画面をスクロールする

等の処理は content_scripts のみが行える仕事です。

つまり、上記フローには両者の相互通信が必要になり、具体的には、

  1. content_scripts が左上へスクロールを行う
  2. background がキャプチャを行う
  3. background から content_script へ次の座標へスクロールの依頼をする
  4. content_scripts はスクロールが完了次第 background へ応答する
  5. 応答を受け、background は再度キャプチャを行う

というやり取りを行うこととなります。
ちょうどバックエンドとフロントエンドの Ajax 通信と似てるので、同じノリで開発することにしましょう。

transform: scale() を使用して一発で全面キャプチャを撮る(低解像度)

ここまで説明しておいてなんですが、画像が荒くてもいいのであれば一発で全面キャプチャを撮る方法があります。
css の transform: scale() を使用して、Web サイト全面を現在の表示領域に収まるまで縮小してキャプチャすればいいのです。

Sizing.ts
import {FindStyle} from "./FindStyle";

export class Sizing {

  //~略~

  /**
   * style タグを挿入する
   * 既にこのクラスが扱っている style が存在した場合はリセットする
   * @param style
   * @private
   */
  private _appendStyle(style: string) {
    //リセット
    this._removeStyle();

    //style タグを用意
    const tag = document.createElement('style');
    tag.setAttribute('id', this.STYLE_ID);
    tag.innerText = style;

    //tag タグ挿入
    document.head.appendChild(tag);
  }

  //~略~

  /**
   * 各種情報をアップデートする
   * @private
   */
  private _updateInformation(max: boolean) {
    //全要素サイズ取得用インスタンス
    let findStyle = new FindStyle(document.getElementsByTagName('html')[0]);

    //ウィンドウサイズ
    this.windowWidth = window.innerWidth;
    this.windowHeight = window.innerHeight;

    //ドキュメントサイズの最大値を取得するリスト
    let widthSources = [document.body.clientWidth, document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth];
    let heightSources = [document.body.clientHeight, document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight];

    //もし max === true だったら取得リストに全要素の最大値を加える
    if (max) {
      widthSources.push(findStyle.highSize('width'));
      heightSources.push(findStyle.highSize('height'));
    }

    //ドキュメントサイズ
    this.documentWidth = Math.max(...widthSources);
    this.documentHeight = Math.max(...heightSources);

    //幅と高さそれぞれの割合
    const widthRatio = this.windowWidth / this.documentWidth;
    const heightRatio = this.windowHeight / this.documentHeight;

    //ratio と ratioType のセット
    this.ratio = widthRatio > heightRatio ? heightRatio : widthRatio;
    this.ratioType = widthRatio > heightRatio ? 'height' : 'width';

    //ratio が 1 以上だったら 1 とする
    this.ratio = this.ratio > 1 ? 1 : this.ratio;

    //~略~
  }

  /**
   * フルサイズ用のサイジング処理を行う
   */
  public fullSizing(): Coordinates {
    //style タグを生成
    this._appendStyle('body{overflow:hidden;transform-origin: left top;transform: scale('+this.ratio+')}');

    //スクロール位置を 0 にする
    window.scrollTo(0, 0);

    //0, 0 を返す
    return {
      x: 0,
      y: 0
    };
  }

  //~略~
}

上記はスクロールなどの処理を行うクラスの一部ですが、ここでは現在のウィンドウサイズとドキュメントサイズを比較して body の大きさを現在のウィンドウに収まる大きさにしています。
こうしてしまえば画像はかなり小さくなってしまうもののキャプチャが一発で済み非常に高速なので、実際の拡張機能ではモードの一つとして採用しています。

完全な全面キャプチャの撮影処理

現在の Web ページ情報と設定を取得する

キャプチャのために必要な情報は以下の三種類です。

  • 現在開いているタブ
  • そのタブで表示されているページ情報
  • 拡張機能の設定

これら情報を一気に取得するための関数を作成しましょう。

background.ts

import {Information, Settings, Range} from "src/class/interface";
import {Capturing} from "./class/Capturing";
import {Filename} from "./class/Filename";
import './config';
import {CAPTURE_WAIT_MILLISECONDS, DEFAULT_COUNTER, DEFAULT_RANGE, DEFAULT_MAX, DEFAULT_TITLE, FIRST_CAPTURE_WAIT_MILLISECONDS} from "./config";

interface InitData {
  tab: chrome.tabs.Tab,
    settings: Settings,
      information: Information
}

{

  /**
   * 拡張機能の設定と現在参照中のタブ情報を返す
   */
  const init = () => {
    /**
     * range を Range 型にキャストする
     * @param range
     */
    const castRange = (range: string): Range => {
      switch (range) {
        case 'full':
        case 'display':
        case 'perfect':
          return range;
          break;
        default:
          return 'full';
          break;
      }
    };

    return new Promise<InitData>(resolve => {
      //現在開いているタブを入手
      new Promise<chrome.tabs.Tab>(innerResolve => {
        chrome.tabs.query({active: true}, (tabs: chrome.tabs.Tab[]) => {
          innerResolve(tabs[0]);
        });
      })
        .then(tab => {
        //拡張機能の設定を入手
        return new Promise<{tab: chrome.tabs.Tab, settings: Settings}>(innerResolve => {
          chrome.storage.sync.get({range: DEFAULT_RANGE, title: DEFAULT_TITLE, counter: DEFAULT_COUNTER, max: DEFAULT_MAX}, (items: {[key: string]: string}) => {
            innerResolve({tab, settings: {range: castRange(items.range), title: String(items.title), counter: Number(items.counter), max: Boolean(items.max)}});
          });
        });
      })
        .then((data: {tab: chrome.tabs.Tab, settings: Settings}) => {
        //現合表示しているタブの情報を入手
        chrome.tabs.sendMessage(Number(data.tab.id), {type: 'information', max: data.settings.max}, (information: Information) => {
          resolve({tab: data.tab, settings: data.settings, information});
        });
      });
    });
  };

  //~略~

}

Promise だらけですが、要は各 Promise で上記情報を取得しています。

Google が提供する API は先ほども登場した chrome.storage.sync.get() のようにコールバックを引数に仕込むことで非同期処理を実現するものが殆どです。

しかし API を複数実行する場合は典型的なコールバック地獄になるため、各 API を自前の Promise でラップしてあげるのが基本方針となります。

最終的に init() 自体もそれら全てをラップした Promise を返すようにすることでこれら情報をひとまとめに受け取るということですね。

さて、ここで注目したいのが実際に表示しているページの情報を取得している、

background.ts
chrome.tabs.sendMessage(Number(data.tab.id), {type: 'information', max: data.settings.max}, (information: Information) => {
  resolve({tab: data.tab, settings: data.settings, information});
});

の部分です。

chrome.tabs.sendMessage() は第一引数で指定したタブで動いている content_scripts に対して第二引数のオブジェクトを渡す API です。

第三引数は content_scripts から返ってきたオブジェクトを受け取るコールバックとなっています。

さっそく今回の拡張機能における受け取り側を見てみましょう。

page.ts
import {Range, Coordinates} from "./class/interface";
import {Sizing} from "./class/Sizing";
import {FindStyle} from "./class/FindStyle";
window.addEventListener('load', () => {

  //~略~

  //ここに関数や変数を定義する

  //~略~

  //メッセージパッシング
  chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    // 受け取った値で分岐
    switch (request.type) {
      case 'information':
        sendResponse(information(request.max));
        break;
      case 'sizing':
        sendResponse(styling(request.range, request.index, request.max));
        break;
      case 'killFixed':
        controlFixed('hidden');
        sendResponse({});
        break;
      case 'resetSizing':
        controlFixed('');
        resetSizing({x: request.x, y: request.y});
        sendResponse({});
        break;
      default:
        sendResponse({});
        break;
    }
  });


  //~略~

  //ここに関数や変数を定義する

  //~略~

});


バックグラウンドページと違い、 content_scripts として登録された .js ファイルは通常の Web サイトに <script> タグで埋め込んだ JavaScript ファイルと同じようにふるまいます。

しかし特異かつ肝なのは下記の部分です。

page.ts
  //メッセージパッシング
  chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {}

先ほどの chrome.tabs.sendMessage がバックグラウンドから呼ばれると、この chrome.runtime.onMessage イベントに登録された関数が実行されます。

このイベントに登録された関数は三つの引数を受け取りますが、今回用があるのはバックグラウンドで chrome.tabs.sendMessage の第二引数に仕込んだ情報である第一引数(request)と、レスポンスを返すときに使う関数である第三引数(sendResponse)です。

chrome.runtime - Google Chrome (onMessage イベント)

上記にもある通り、request の中に type として仕込んだ string で実際に起動する関数を選択し、その返り値を sendResponse() でバックグランドページに返してあげれば複数の要件で二つのスクリプト間のメッセージングが可能となるわけです。

今回バックグラウンドから呼ばれた要求は type :information なので、page.tsinformation() を見てみましょう。

page.ts

  //サイズを取得するためのクラス
  const sizing = new Sizing();

  //~略~

  //表示されているタブの情報を返す
  const information = (max: boolean) => {
    return sizing.getInformation(max);
  };

Sizing.ts
import {Coordinates, Information} from "src/class/interface";
import {FindStyle} from "./FindStyle";

export class Sizing {

  //constructor() 時点の window width
  private windowWidth: number = 0;

  //constructor() 時点の window height
  private windowHeight: number = 0;

  //constructor() 時点の document width
  private documentWidth: number = 0;

  //constructor() 時点の document height
  private documentHeight: number = 0;

  //画面縮小比率
  private ratio: number = 0;

  //画面を幅と高さのどちらで縮小したか
  private ratioType: 'width' | 'height' = 'height';

  //documentWidth を現在の windowWidth の大きさでキャプチャするには横に何枚キャプチャが必要か
  private widthCaptureNumber: number = 0;

  //documentHeight を現在の windowHeight の大きさでキャプチャするには縦に何枚キャプチャが必要か
  private heightCaptureNumber: number = 0;

  //上記二つの乗算値
  private captureNumber: number = 0;

  //constructor() 時点のスクロール位置(横)
  private scrollX: number = 0;

  //constructor() 時点のスクロール位置(縦)
  private scrollY: number = 0;

  /**
   * 各種情報をアップデートする
   * @private
   */
  private _updateInformation(max: boolean) {
    //全要素サイズ取得用インスタンス
    let findStyle = new FindStyle(document.getElementsByTagName('html')[0]);

    //ウィンドウサイズ
    this.windowWidth = window.innerWidth;
    this.windowHeight = window.innerHeight;

    //ドキュメントサイズの最大値を取得するリスト
    let widthSources = [document.body.clientWidth, document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth];
    let heightSources = [document.body.clientHeight, document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight];

    //もし max === true だったら取得リストに全要素の最大値を加える
    if (max) {
      widthSources.push(findStyle.highSize('width'));
      heightSources.push(findStyle.highSize('height'));
    }

    //ドキュメントサイズ
    this.documentWidth = Math.max(...widthSources);
    this.documentHeight = Math.max(...heightSources);

    //幅と高さそれぞれの割合
    const widthRatio = this.windowWidth / this.documentWidth;
    const heightRatio = this.windowHeight / this.documentHeight;

    //ratio と ratioType のセット
    this.ratio = widthRatio > heightRatio ? heightRatio : widthRatio;
    this.ratioType = widthRatio > heightRatio ? 'height' : 'width';

    //ratio が 1 以上だったら 1 とする
    this.ratio = this.ratio > 1 ? 1 : this.ratio;

    //縦と横においてそれぞれ現在のウィンドウサイズ何枚分で全画面を捕捉できるかの数値を算出
    this.widthCaptureNumber = Math.ceil(this.documentWidth / this.windowWidth);
    this.heightCaptureNumber = Math.ceil(this.documentHeight / this.windowHeight);

    //上記二つの乗算値
    this.captureNumber = this.widthCaptureNumber * this.heightCaptureNumber;

    //現在のスクロール座標を記録
    this.scrollX = window.scrollX;
    this.scrollY = window.scrollY;
  }

  /**
   * 情報を返す
   * @return {{documentWidth: number | *, documentHeight: number | *, windowHeight: number | *, ratioType: string, windowWidth: number | *, ratio: (*|number)}}
   */
  public getInformation(max: boolean = false): Information {
    //情報の更新
    this._updateInformation(max);

    //計算結果を返す
    return {
      windowWidth: this.windowWidth,
      windowHeight: this.windowHeight,
      documentWidth: this.documentWidth,
      documentHeight: this.documentHeight,
      widthCaptureNumber: this.widthCaptureNumber,
      heightCaptureNumber: this.heightCaptureNumber,
      captureNumber: this.captureNumber,
      ratio: this.ratio,
      ratioType: this.ratioType,
      scrollX: this.scrollX,
      scrollY: this.scrollY

  }

//~略~

}

怯んではなりません。後々これだけの情報が必要なのです。

ただし、コードを書いている身としてもこれら情報セットは複雑なのでバグ防止・コード補完のために TypeScript の interfaceInformation を作成し、各コードで使いまわしています。

interface.ts
//~略~
export interface Information {
  windowWidth: number,
  windowHeight: number,
  documentWidth: number,
  documentHeight: number,
  widthCaptureNumber: number,
  heightCaptureNumber: number,
  captureNumber: number,
  ratio: number,
  ratioType: string,
  scrollX: number,
  scrollY: number
}
//~略~

必要なキャプチャ枚数とスクロール座標を算出する

init() で必要な情報は受け取ったので、今度は必要なキャプチャ枚数と、それぞれのスクロール座標を算出しましょう。

大体のサイトは縦長で縦スクロールしか存在しませんが、当然ほとんどの Web ブラウザは二次元の表示領域を持つので横スクロールのことを無視するわけにはいきません。

縦スクロールと横スクロールそれぞれを勘案に入れて、現在表示している Web ページは何枚のキャプチャが必要でしょう?

板チョコを想像してみてください。

板チョコ.png

板チョコ1ピース分が 現在のウィンドウサイズ、つまり見えている部分の大きさだとしましょう。

上の図の二つ目を例にとると、左上に重ねた板チョコ1枚分では Web サイトの横幅全てをカバーすることはできませんが、2枚あれば横幅をカバーすることができそうです。
同様に、縦幅は5枚あればカバーできそうなので、板チョコは計10枚必要であることが分かります。

先ほどは説明を飛ばしましたが、 Information に定義されている captureNumber はまさにこの総枚数に当たり、同様に widthCaptureNumberheightCaptureNumber はそれぞれ行数と列数になります。

Sizing.ts

  private _updateInformation(max: boolean) {
    //~略~

    //ウィンドウサイズ
    this.windowWidth = window.innerWidth;
    this.windowHeight = window.innerHeight;

    //ドキュメントサイズの最大値を取得するリスト
    let widthSources = [document.body.clientWidth, document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth];
    let heightSources = [document.body.clientHeight, document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight];

    //もし max === true だったら取得リストに全要素の最大値を加える
    if (max) {
      widthSources.push(findStyle.highSize('width'));
      heightSources.push(findStyle.highSize('height'));
    }

    //ドキュメントサイズ
    this.documentWidth = Math.max(...widthSources);
    this.documentHeight = Math.max(...heightSources);

    //~略~

    //縦と横においてそれぞれ現在のウィンドウサイズ何枚分で全画面を捕捉できるかの数値を算出
    this.widthCaptureNumber = Math.ceil(this.documentWidth / this.windowWidth);
    this.heightCaptureNumber = Math.ceil(this.documentHeight / this.windowHeight);

    //上記二つの乗算値
    this.captureNumber = this.widthCaptureNumber * this.heightCaptureNumber;

    //~略~
  }

windowWidthwindowHeight がそれぞれ板チョコ1ピース分の widht, height となるので、これら情報でキャプチャ枚数と、それぞれのスクロール座標情報が算出することが可能になりました。

実際にスクロールさせる各板チョコピースの左上座標を算出するには以下の関数を通すと算出できます。

Sizing.ts
  /**
   * 指定された index からスクロールすべき座標を返す
   * @param index
   * @private
   */
  private _getScrollCoordinates(index: number): Coordinates {
    return {
      x: Math.floor(index % this.widthCaptureNumber) % this.captureNumber * this.windowWidth,
      y: Math.floor(index / this.widthCaptureNumber) % this.captureNumber * this.windowHeight
    };
  }

index には処理するピースのインデックス番号を入れるのですが、上記画像の計10枚を例にとるなら、

0 1
2 3
4 5
6 7
8 9

という位置のスクロール座標を返します。

4列3行なら、

0 1 2 3
4 5 6 7
8 9 10 11

といった具合となります。

通常のスクロールではなく transition: translate で疑似的なスクロールを行いながらキャプチャを行う

キャプチャのための情報が出そろったところで素直に window.scrollTo(x, y) 等でスクロールを行ってみると右端・下端でキャプチャが上手くいきません。

当然ながら画面のスクロールは Web サイトの最大幅を超過して行うことはできないからです。

先ほどの板チョコ図を見てみましょう。

板チョコ (1).png

理想としては左図のようにキャプチャしたいところですが、実際にスクロールした場合座標が指定した位置よりも手前で止まってしまい、右端・下端のキャプチャは右図のようにすぐ隣の領域と被ってしまうのです。

また地味に困るのが、Web サイトによってはスクロールによってサイトのデザインが変化する場合があることです。
特に顕著なのがスクロールに応じて固定されたヘッダーが出たり引っ込んだりするサイトです。このようなサイトはいざ下の方のキャプチャを行おうとした際に余計なヘッダーが出現し、後で画像を合成したときに空中に浮いているヘッダーが描画されてしまい、とても見づらいキャプチャ画像となってしまいます。

これらに対処するため、実際にキャプチャ範囲を移動する際は body タグに対して tranform: translate(-x, -y); を指定して疑似的にスクロールを行います。

x座標、y座標共に window.scrollTo(x, y) で指定する値に -1 を掛けた数値を使用しないと逆方向に移動してしまうのに注意しましょう。

translate() - CSS: カスケーディングスタイルシート | MDN

ついでにスクロールバーも画像合成の場合に邪魔になるので overflow: hidden を同じく body タグに指定します。

Sizing.ts

  /**
   * このクラスが仕込んだ style タグを削除する
   * @private
   */
  private _removeStyle() {
    //削除対象の取得
    const target = document.getElementById(this.STYLE_ID);

    //target が存在しなかったら何もしない
    if (target === null) {
      return;
    }

    //対象を削除する
    target.remove();
  }

  /**
   * style タグを挿入する
   * 既にこのクラスが扱っている style が存在した場合はリセットする
   * @param style
   * @private
   */
  private _appendStyle(style: string) {
    //リセット
    this._removeStyle();

    //style タグを用意
    const tag = document.createElement('style');
    tag.setAttribute('id', this.STYLE_ID);
    tag.innerText = style;

    //tag タグ挿入
    document.head.appendChild(tag);
  }

  /**
   * 指定された index からスクロールすべき座標を返す
   * @param index
   * @private
   */
  private _getScrollCoordinates(index: number): Coordinates {
    return {
      x: Math.floor(index % this.widthCaptureNumber) % this.captureNumber * this.windowWidth,
      y: Math.floor(index / this.widthCaptureNumber) % this.captureNumber * this.windowHeight
    };
  }

  //~略~

  /**
   * スクロールバーを消すだけのサイジング処理を行う
   * スクロール位置は index 番号で指定する
   * index が null だった場合はスクロールを変更しない
   * この index 番号は getInformation() で取得できる captureNumber の範囲で指定し、
   * 例えば
   * widthCaptureNumber = 4
   * heightCaptureNumber = 3
   * captureNumber = 12
   * だった場合は
   * +----+----+----+----+
   * |  0  |  1  |  2  |  3  |
   * +----+----+----+----+
   * |  4  |  5  |  6  |  7  |
   * +----+----+----+----+
   * |  8  |  9  | 10 | 11 |
   * +----+----+----+----+
   * といった各マスの左上座標へスクロールすることになる
   * 各マスの width, height = windowWidth, windowHeight
   * 大枠の width, height = documentWidth, documentHeight
   */
  public displaySizing(index: number|null = null, max: boolean = false): Coordinates {
    //index 指定が無かったら style タグを適用の後、(0, 0)を返す
    if (index === null) {
      //style タグを生成
      this._appendStyle('body{overflow:hidden}');

      //(0, 0)返す
      return {
        x: 0,
        y: 0
      };
    }

    //もし index が 0 だったらスクロール位置を 0, 0 にする
    if (index === 0) {
      document.getElementsByTagName('html')[0].scrollTo(0, 0);
    }

    //移動先座標の定義
    const coordinates = this._getScrollCoordinates(index);

    //overflow スタイルの適用 & transform: translate による疑似的なスクロールの実行
    this._appendStyle(max
      ? 'body{overflow:hidden;transform:translate('+(coordinates.x * -1)+'px,'+(coordinates.y * -1)+'px);width: '+this.documentWidth+'px;height: '+this.documentHeight+'px;}'
      : 'body{overflow:hidden;transform:translate('+(coordinates.x * -1)+'px,'+(coordinates.y * -1)+'px)}'
    );

    //スクロール情報を返す
    return coordinates;
  }

この Sizing.displaySizing() を手に入れたキャプチャ定義ごとに呼べば望み通りの位置に画面が移動します。

page.ts
import {Range, Coordinates} from "./class/interface";
import {Sizing} from "./class/Sizing";
import {FindStyle} from "./class/FindStyle";
window.addEventListener('load', () => {

  //~略~

  //ブラウザの大きさを適切なものに変える
  const styling = (range: Range, index: number, max: boolean) => {
    //処理終了後の座標情報
    let coordinate: Coordinates = {
      x: 0,
      y: 0
    };

    //range によって処理を分ける
    switch (range) {
      case 'full':
        coordinate = sizing.fullSizing();
        break;
      case 'perfect':
        coordinate = sizing.displaySizing(index, max);
        break;
      default:
        coordinate = sizing.displaySizing(null);
        break;
    }

    //座標情報を返す
    return coordinate;
  };

  //~略~

  //メッセージパッシング
  chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    // 受け取った値で分岐
    //~略~
      case 'sizing':
        sendResponse(styling(request.range, request.index, request.max));
        break;
    //~略~
  });

  //~略~

});

background.ts
import {Information, Settings, Range} from "src/class/interface";
import {Capturing} from "./class/Capturing";
import './config';
import {CAPTURE_WAIT_MILLISECONDS, DEFAULT_COUNTER, DEFAULT_RANGE, DEFAULT_MAX, DEFAULT_TITLE, FIRST_CAPTURE_WAIT_MILLISECONDS} from "./config";

{
  //Capturing クラス
  const capturing = new Capturing();

  //~略~

  /**
     * 現在表示しているタブのキャプチャを一回行う
     * @param id
     * @param range
     */
  const createCapture = (id: number, range: Range, index: number, max: boolean = false): Promise<void> => {
    return  new Promise(resolve => {
      //index === 1 (キャプチャが二回目)の場合は position: fixed の要素を非表示にする
      if (index === 1) {
        chrome.tabs.sendMessage(id, {type: 'killFixed'});
      }

      //スクロール・キャプチャ
      chrome.tabs.sendMessage(id, {type: 'sizing', range: range, index: index, max: max}, response => {
        setTimeout(() => {
          capturing.capture(response.x, response.y)
            .then(() => {
            resolve();
          });
        }, index < 2 ? FIRST_CAPTURE_WAIT_MILLISECONDS : CAPTURE_WAIT_MILLISECONDS);
      });
    });
  };

  /**
     * settings と information から求められている画像サイズを導き出す
     * @param settings
     * @param information
     */
  const getImageSize = (settings: Settings, information: Information): {width: number, height: number} => {
    //最終的な画像サイズを決定(この時点では range = display 用)
    let width = information.windowWidth;
    let height = information.windowHeight;

    //range に合わせた画像サイズを用意
    switch (settings.range) {
      case 'full':
        width = information.ratioType === 'width'
          ? information.windowWidth
        : information.windowWidth * information.ratio;
        height = information.ratioType === 'height'
          ? information.windowHeight
        : information.windowHeight * information.ratio;
        break;
      case 'perfect':
        width = information.documentWidth;
        height = information.documentHeight;
        break;
    }

    //返す
    return  {width, height};
  };

  /**
     * 現在開いているタブのキャプチャを行う
     * @param settings
     * @param information
     * @param tab
     */
  const getDataURL = async (settings: Settings, information: Information, tab: chrome.tabs.Tab) => {
    //何枚の画像をキャプチャするか
    const captureNumber = settings.range === 'perfect'
    ? information.captureNumber
    : 1;

    //サイズ取得
    const size = getImageSize(settings, information);

    //キャプチャ処理を必要な回数だけ行う
    for (let i = 0, max = captureNumber; i < max; i = (i + 1) | 0) {
      await createCapture(Number(tab.id), settings.range, i, settings.max);
    }

    //スタイルを元に戻す
    chrome.tabs.sendMessage(Number(tab.id), {type: 'resetSizing', x: information.scrollX, y: information.scrollY});

    //dataURL 化
    return capturing.compose(size.width, size.height);
  };

  //~略~

}

Caputuring クラスは後で説明しますが、 capture() でキャプチャした画像データをクラス内に溜めておき、compose で合成した画像データを返す役割を担っています。

このキャプチャを行う処理は直列の非同期処理ですが、画面条件によって何枚のキャプチャが必要になるかは動的に決定されるため、then() による Promise チェーンを繋ぐコードが静的に書けません。

このように動的な数の非同期処理を直列実行する場合は Array.reduce() を駆使するのが定番ですが、今回は全ブラウザに対応する必要が無い拡張機能の制作ということで async/await を使用してもう少し分かりやすく実装することにしました。

getDataURL()async がつけてあるので内部で await が使用可能になり、

background.ts
for (let i = 0, max = captureNumber; i < max; i = (i + 1) | 0) {
  await createCapture(Number(tab.id), settings.range, i, settings.max);
}

の部分は前の createCapture() が終了(= Promise.resolve() が返るまで)するまで次の createCapture() の実行が待たれるようになります。

スクロールバーと position: fixed を非表示にする

先ほども軽く触れましたが、スクロールに関する問題を解決してもやはり固定された要素は厄介です。

サイト上部へ常に固定されているヘッダーをはじめ、サイト右下に居座る TOP へ戻るボタンや、妙なタイミングで出てくるモーダルウィンドウ等、こういった要素は 宇宙に存在する全ての position: fixed を許さない過激派 のみならず、サイトのキャプチャを撮ろうとする我々開発者をも憎悪の渦に叩きこんできました。

これら要素のうち大抵のものは先述の疑似スクロールによって固定されずに済むものの、やはり2枚目以降のキャプチャ取得時には非表示にしたいものです。

なので、この拡張機能では愚直に全要素を取得し、postion: fixed が適用されている要素のみ一時的に visibility: hidden で非表示にしています。

実装前はかなり重たい処理になるかと思われましたが、実際に作ってみると大抵のサイトで 0.1 秒もかからず全ての要素を非表示にすることができたので、以下のコードを採用しました。
自分で言うのもなんですが、これだけで別の拡張機能が作れそうですね。

FindStyle.ts
export class FindStyle {

  /**
   * constructor で挿入する HTMLElement
   * この要素にぶら下がっている DOM ツリーが対象
   */
  readonly root: HTMLElement;

  /**
   * root 下の全 HTMLElement
   * 階層楮は無く、一次元配列として捕捉
   */
  private elements: HTMLElement[];

  /**
   * target が null でないことを保証する Type guard
   * HTMLElement.children から取ってきたオブジェクトに対して用いる
   * @param target
   * @private
   */
  private _isHTMLElement(target: any): target is HTMLElement {
    return  target !== null;
  }

  /**
   * parent 下にぶら下がる DOM ツリーを再帰的に取得し、this.elements に追加する
   * @param parent
   * @private
   */
  private _findChildren(parent: HTMLElement) {
    //自身をpush
    this.elements.push(parent);

    //子要素の取得
    const children = parent.children;

    for (let i = 0, max = children.length; i < max; i = (i + 1) | 0) {
      //タイプガードを通すため、一旦変数へ格納
      const target = children.item(i);

      //target が null でないことを保証
      if ( ! this._isHTMLElement(target)) {
        continue;
      }

      //再帰的にこの関数を呼ぶ
      this._findChildren(target);
    }
  }

  /**
   * ドキュメントルートを確保し、検索対象の要素を捕捉する
   * @param root
   */
  constructor(root: HTMLElement) {
    //検索対象ツリーの親要素を登録
    this.root = root;

    //検索結果配列を初期化
    this.elements = new Array();

    //検索開始
    this._findChildren(root);
  }

  /**
   * css として property: value が適用されている要素を this.elements から取得する
   * @param property
   * @param value
   */
  public find(property: string, value: string): HTMLElement[] {
    //このメソッドが返す配列の用意
    let result = new Array();

    //捕捉済みの要素を逐一検索
    for (let i = 0, max = this.elements.length; i < max; i = (i + 1) | 0) {
      //計算済み css が合致していなかったらスルー
      if (window.getComputedStyle(this.elements[i]).getPropertyValue(property) !== value) {
        continue;
      }

      //該当要素として検索結果配列に追加
      result.push(this.elements[i]);
    }

    //該当要素を返す
    return result;
  }

  /**
   * 全要素中で最大の width, もしくは height を返す
   * @param target
   */
  public highSize(target: 'width' | 'height' = 'height'): number{
    //このメソッドが返す数値
    let result: number = 0;

    //捕捉済みの要素を逐一検索
    for (let i = 0, max = this.elements.length; i < max; i = (i + 1) | 0) {
      //サイズの計測対象(width or height)
      const size = target === 'height'
        ? this.elements[i].getBoundingClientRect().height
        : this.elements[i].getBoundingClientRect().width;

      //result 以下だったらスルー
      if (result >= size) {
        continue;
      }

      //最高値を書き換える
      result = size;
    }

    //結果を返す
    return result;
  }

}

page.ts
import {FindStyle} from "./class/FindStyle";
window.addEventListener('load', () => {

  //~略~

  //position: fixed を採用している要素
  let fixedElements: HTMLElement[] = [];

  //position: fixed を採用している要素を確保する
  const getFixed = () => {
    const findStyle = new FindStyle(document.body);
    fixedElements = findStyle.find('position', 'fixed');
  }

  //position: fixed を採用している要素を非表示にする or 元に戻す
  const controlFixed = (property: 'hidden' | '') => {
    for (let i = 0, max = fixedElements.length; i < max; i = (i + 1) | 0) {
      fixedElements[i].style.visibility = property;
    }
  };

  //~略~

  //position: fixed を採用している要素の確保
  getFixed();

});

HTML Canvas にキャプチャデータを並べてトリミングする

詳細な説明を後回しにしていた Capturing クラスですが、コードは次のようになっています。

Capturing.ts
interface CaptureURL {
  url: string,
  x: number,
  y: number
}

export class Capturing {

  //キャプチャ済み DataURL の集合
  private captureURLs: CaptureURL[] = [];

  /**
   * target が CanvasRenderingContext2D であるか判定する
   * 具体的には drawImage メソッドが存在するか判定する
   * @param target
   */
  private _isCanvasRenderingContext2D = (target: any): target is CanvasRenderingContext2D => {
    return target.drawImage !== undefined;
  }

  /**
   * 現在 captureURLs に読み込まれているデータをカンバスに読み込み、合成、トリミングする
   * 最終的に吐き出される画像の大きさは width * height となる
   * @private
   */
  public compose = async (width: number, height: number): Promise<string> => {
    //カンバスの作成
    const canvas = document.createElement('canvas');

    //カンバスの大きさを設定
    canvas.setAttribute('width', width+'px');
    canvas.setAttribute('height', height+'px');

    //2D コンテキストを取得
    const ctx = canvas.getContext('2d');

    //ctx のタイプガード
    if ( ! this._isCanvasRenderingContext2D(ctx))
    {
      return '';
    }

    //カンバスに画像を設置
    await this.captureURLs.reduce((prev, current) => prev.then(() => {
      return new Promise(resolve => {
        const image = new Image();
        image.onload = () => {
          ctx.drawImage(image, current.x, current.y);
          resolve();
        };
        image.src = current.url;
      });
    }), Promise.resolve());

    //dataURL を生成
    const data = canvas.toDataURL();

    //canvas を消す
    canvas.remove();

    //dataURL を返す
    return data;
  };

  /**
   * キャプチャを取得し、captureURLs に push する
   * @param x
   * @param y
   * @private
   */
  public capture(x: number, y: number): Promise<void> {
    return new Promise(resolve => {
      chrome.tabs.captureVisibleTab((url) => {
        this.captureURLs.push({x, y, url});
        resolve();
      });
    });
  }

  /**
   * captureURLs を空にする
   */
  public init() {
    this.captureURLs = [];
  }

}

要は現在のスクロール位置を指定しながら capture(x, y) を呼ぶとクラス内部の captureURLs にキャプチャデータが溜まっていき、compose(width, height) で指定した大きさの画像にトリミングして DataURL を返してくれるクラスです。バックグラウンドから呼びます。

capture(x, y) では最初の方に説明した chrome.tabs.captureVisibleTab() API を使用してキャプチャを取得しています。

compose(width, height) は一見 string を返すように見えますが、先述したように async を指定している関数のため、実際にかえる値は TypeScript で指定している通り Promise です。

またバックグラウンドページで実行しているコードとはいえ、node.js とは違いグローバルに document が存在するため、Canvas API が使用可能です。
使用したところでバックグラウンドページなのでユーザーの目に触れることはありませんが、今回は画像合成が目的なので特に問題ありません。

ctx.drawImage で画像を並べた後、ctx.todDataURL() で PNG 画像のバイナリ文字列を手に入れます。

async function - JavaScript | MDN

ファイル名を決定してダウンロードする

最後にバイナリデータを画像ファイルとしてダウンロードしましょう。

ダウンロード自体は先述したように chrome.downloads.download で実現可能ですが、その前に画像ファイルの名前を決める必要があります。

ctx.todDataURL() で引数を設定しなかったので拡張子は .png で確定なのですが、この拡張機能ではファイル名をユーザーが設定できる機能を備えています。
設定画面で扱った title がファイル名となって出力されるのですが、固定文字のみならず変数として使えるテンプレート文字列が以下の三種類です。

  • {{title}}: ページタイトルに変換
  • {{url}}: URLに変換
  • {{counter}}: 連番に変換

これら文字列を該当する文字列に変換しつつ、かつファイル名として使用できない文字を除外する必要があるのですが、これら機能をベタにバックグラウンドスクリプトへ記述すると煩雑なので SigzingCapturing と同じように別クラスへと切り出しました。

Filename.ts
/**
 * ファイルネーム作成クラス
 */
import {Templates} from "./interface";

export class Filename {

  /**
   * 置き換え定義
   */
  private templates: Templates;

  /**
   * ファイル名に使用できない文字を全て replacement に置換して返す
   * @param string
   * @param replacement
   * @return {string}
   * @private
   */
  private _replaceBadCharacter(string: string, replacement: string = '_') {
    return String(string).replace(/[\\\/:\*\?"<>\-\|\s]+/g, replacement);
  }

  /**
   * this.templates の定義
   */
  public constructor() {
    this.templates = new Array();
  }

  /**
   * テンプレート変数文字列とその値を設定する
   * @param template
   * @param value
   */
  public setTemplate(template: string, value: string) {
    this.templates.push({
      template: String(template),
      value: String(value)
    });
  }

  /**
   * setTemplate(), _replaceBadCharacter() で変換したファイル名を出力
   * @param name
   * @return {string}
   */
  public getFileName(name: string): string {
    //テンプレート変数文字列を値に置き換える
    for (let i = 0, max = this.templates.length; i < max; i = (i + 1) | 0) {
      name = String(name).replace(new RegExp(this.templates[i].template, 'g'), this.templates[i].value);
    }

    //使用不可の文字を全て置き換えて返却
    return this._replaceBadCharacter(name);
  }

}

まず、正規表現は使用できませんがString.replace() を使用するような感覚で Filename.setTemplate() で事前に置換対象文字列と置換後の文字列を指定します。

そして getFileName() の引数にユーザーが設定したファイル名文字列(例: '{{title}}_{{counter}}')を仕込んで呼べば拡張子より前のファイル名が取得できます。

background.ts
    /**
     * ファイル名を決定し、ダウンロードを行う
     * @param url
     * @param settings
     */
    const download = (url: string, settings: Settings, tab: chrome.tabs.Tab) => {
        //ファイル名変換用クラス
        const filename = new Filename();

        //ファイル名テンプレート変数文字列登録
        if (settings.title.indexOf('{{title}}') !== -1) {
            filename.setTemplate('{{title}}', decodeURIComponent(String(tab.title)));
        }
        if (settings.title.indexOf('{{url}}') !== -1) {
            filename.setTemplate('{{url}}', String(tab.url).replace(/https?:\/\//, ''));
        }
        if (settings.title.indexOf('{{counter}}') !== -1) {
            filename.setTemplate('{{counter}}', String(settings.counter));
            settings.counter = settings.counter + 1;
        }

        //counter 設定の保存
        chrome.storage.sync.set({counter: settings.counter});

        //ダウンロード
        chrome.downloads.download({url: url, filename: filename.getFileName(settings.title)+'.png'});
    };

このようにファイル名を取得し、chrome.downloads.download を呼んでやれば画像をダウンロードすることができます。

以上で拡張機能完成です。

感想

めんどくせえ!

でもなんだかんだで楽しかったです。

近々 chrome.tabs.captureFullVisibleTab が実装されたら立ち直れないかも。

19
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
18