Attach an image from clipboard

ついで、クリップボードに画像が格納されている時、コメント欄で Ctrl+V を押すとそれを直接添付ファイルにする機能を実装してみよう。

基本的にはコメント欄の paste イベントで clipboardData.files を見てその中から画像っぽいものを取り出し、それを覚えておき、投稿時に upfile 要素の内容の代わりに使用する、だけ。

のだが、いくつかめんどくさい点がある。

  • 貼り付けた画像のプレビューを生成しなければならない。これ自体は普通に upfile 要素で選択された画像をプレビューする処理と同じで何か新しくコードを書くわけではない。ただし、基本的な流れは画像を示す file インスタンスの内容を img 要素に割り当て、読み込み完了後に canvas 要素に縮小描画するというもので、特に file→img を従来は data スキーム経由で行っていた。しかしこれはけっこう重い。そんなわけでそこを createObjectURL/revokeObjectURL で行うようにした。今までそうしなかったというのはつまり Presto Opera でも動かすためだ。でももう Presto Opera のことは忘れる
  • 貼り付けられた画像のサイズが、板に貼れる上限を超えている場合。クリップボードに格納されている画像は、おそらく確実にブラウザへは image/png 形式で渡ってくる。png なのでサイズ的には常に大きめだ。それが板の上限を超えていた場合は jpeg でエンコードし直す必要がある。

    その場合は file を canvas に描画した後 toDataURL(‘image/jpeg’) で jpeg エンコードされた data スキームの URL を得て、それを XHR で読み込んで arraybuffer として取得し、そこから blob を生成…となる。途中で生成した canvas はそのままプレビュー生成にも使う。この辺、なんか回りくどい。canvas に blob を返すエンコードメソッドを設けてくれれば、それを FileReader から好きに読み込めばいいので汎用的だと思うのだけど…。

    あと、真面目に作るなら、jpeg エンコードしてもなお上限を超えている場合を考慮して、段階的に jpeg のクオリティを下げていくようなループにするべきなのだが、そこまではしなかった。

    ちなみに板に貼れる上限というのは注意書きの中で例えば「2000KBまで」とか書いてあるのをパースして覚えておくのですが。2000KB というのは 2000*1024=2048000byte のことでいいんだろうか。それとも 2000*1000=2000000byte か? 試してみた所 2000000byte 超のファイルは受け入れられるようなので前者とみなすようにした

だいたいこんな感じ。

Saving an image via Akahukuplus

虹裏にはたまにいわゆる虹裏ブラウザを話題とするスレが立つのだが、赤福プラスが俎上に載ることはめったにない。そしてまれに載った際はたいてい、使いにくくて機能の少ないクソと烙印を押されるのがオチなのであった。不満の一つに画像の保存をローカルに対して行えないという点があるようなので、それに対応してみよう。

もちろん Chrome のエクステンションからローカルのファイルシステムを直接は操作できないのだが、拙作のLFO – ローカルファイル操作ライブラリを通すと不思議なことにファイルの読み書きは自由にできちゃうので、これを使う。すでに wasavi と kokoni が LFO を使用している。これに赤福プラスも加わることになる。

LFO 側で適宜設定を行ったあと、赤福プラスの設定でファイル保存名テンプレートを[cci]ピクチャ/ふたば/$SERVER/$BOARD/$YEAR-$MONTH/$DAY-$SERIAL.$EXT[/cci]などと設定し、使用するストレージを[cci]local[/cci]にする。で、保存リンクを押せばそのとおりの場所に保存される。これだけ。あとは画像を開いた際に自動的に保存、みたいなオプションもあればいいかな。

技術的には、任意のパスに保存するには画像本体の保存に先駆けていわゆる [cci]mkdir -p[/cci] 的な動作が必要になる。現バージョンの LFO はこの機能を持っていないのでまずそこから実装する必要があった。LFO というか、Chrome Apps の FileSystem API 自体にそんな素敵な機能はない。従って、パス中の個々のディレクトリごとに一つずつ掘っていくという繰り返し処理を書くことになる。これを FileSystem API を直接呼びながら実現するとコードがものすごく汚くなりそうだったので、Promise を使うようにした。ついでなので、他の LFO のコマンドもすべて Promise ベースで動作するように書き直した。

ちなみにこの機能は Chrome 専用だ(Chrome Apps が Firefox や Opera で動くならその限りではないが)。

Prevent from executing an inline script

以前にも書いた気がするが、赤福プラスはふたばが返す html をまるごと変換し、上書きする。従って、元の html に記述されている画像や、スクリプトや、インラインフレームの読み込みはまったく不要で、ブロックする必要がある。ブロックした上で、変換後の html から改めて読み込まなければならない。

そんなわけで、Chrome では WebRequest API を用いてそれを実現していたのだが、ただ一つ html に直接記述された script 要素、つまりインラインスクリプトの実行は見逃していた。まあこれを見逃しても src 属性付きのスクリプトの読み込みはブロックしているので、大抵のインラインスクリプトはなんちゃらが定義されていませんエラーになって実害はないのだが、気にはなる。

しかし、Chrome のエクステンションの API を眺めてみてもインラインスクリプトの実行をブロックする機能は見つからない。いやあることはある。例えば WebRequest でレスポンスヘッダに CSP を忍ばせて、インラインスクリプトを除外するとか、あるいは ContentSettings でふたば上のみの javascript の実行を禁止するとか。が、リファレンスを読んでみるといずれも html を読み込んでから DOMContentLoaded までの短い期間だけスクリプトの実行を抑制するという要件にはちょっと合ってない。

いろいろ調べてみたところ、content script で MutationObserver を用いて script 要素が DOM ツリーに追加された瞬間をキャッチし、type を text/javascript とか application/javascript から、スクリプトとして実行されないものに書き換えるとそういう動作を実現できるようだ。で、用が済んだら、DOMContentLoaded ハンドラで disconnect() すればいい。完璧だ。

ただし Firefox ではこの type 書き換え法が効かないので、その代わり script 要素の beforescriptexecute イベントをリスンして適宜 preventDefault() する。つまり実行タイミングを start_at にした content script から

var observer = new MutationObserver(ms => {
. function handleBeforeScriptExecute (e) {
. . e.target.removeEventListener(
. . . 'beforescriptexecute', handleBeforeScriptExecute, false);
. . e.preventDefault();
. };
. ms.forEach(m => {
. . m.addedNodes.forEach(node => {
. . . if (node.nodeType != 1 || node.nodeName != 'SCRIPT') return;
. . . node.type = 'text/plain';
. . . node.addEventListener(
. . . . 'beforescriptexecute', handleBeforeScriptExecute, false);
. . });
. });
});
observer.observe(document.documentElement, {
. childList: true,
. subtree: true
});

とこんな感じのコードを走らせる。

ちなみに Presto Opera だとこんな長いコードを書かなくても、

window.opera.addEventListener('BeforeScript', function(e){e.source=''}, false);

だけで実現できる。オーパーツすぎる…。

そういえば忘れていたけど、次のリリースから赤福プラスは Presto Opera をもうサポートしない。

cVim

Chrome に cVim を入れて常用ブラウザとしている。そして、多少 cVimrc をいじって、その中に編集可能な要素内で [cci]Ctrl+H[/cci] が押されたら、[cci]deleteChar[/cci] 関数を呼び出すような設定にしている。

…のだが、最近になってこれが動かなくなってしまった。キャレットの左の 1 文字が選択されるのだが、削除されない。選択されたままになる。

デバッガで追ってみると、選択した後にその選択範囲を削除しようとする箇所で、selection オブジェクトの type が Range であれば削除する…となっている処理がある。しかしナウい Chrome では、編集可能な要素の selection.type は Caret が返されるようなのだ。なので、削除処理がスキップされる。

まあ、そのうち直るんでしょう。cVim のソースは現在でもそれなりにメンテされている。

Save to #4

いくつか気なる点が出始めてきた。

Kokoni のコンテキストメニューは、image/video/audio に対するものと link に対する物の両方に対して表示するようになっている。ここで、例えば img 要素を含んだ a 要素に対してコンテキストメニューを出したとき、保存対象は a.href か img.src か? という難しい判断を迫られる。

Save file to ではこういう場合、リンク先を保存というメニュー項目と画像を保存というメニュー項目を両方出現させる。しかし Chrome の拡張の場合、それは不可能なのだった。コンテキストメニューのルートに複数のメニュー項目を追加しようとした場合、ルートには拡張の名前の項目だけが追加され、拡張側から追加しようとした項目はその下に迂回されてしまう。

つまり通常はコンテキストメニューから “ここに保存” -> “Pictures” というルートを辿るのが、上記のマークアップの場合は “Kokoni” -> “ここにリンクを保存” または “ここに画像を保存” -> (いずれかの)”Pictures” というように展開されるメニューが1段増える。

リンクに対するコンテキストメニューを出した時はそういうものだと受け入れるとして、問題はどうも拡張から生成できるメニュー項目数に妙な制限があるらしいことなのだった。つまりツリーに対応するメニュー項目群を2倍定義しないといけないのだが、そうすると簡単に制限に引っかかる可能性が高くなる。メニュー項目をより動的に生成できるようなイベントが用意されていればその都度必要とされる項目だけを生成することで解決するのだが、chrome のコンテキストメニュー API はいまいちその辺の融通が効かない。

もうひとつ、参照したディレクトリの履歴をコンテキストメニューに含めるようにしているが、現状では単純に最後に参照したディレクトリを先頭に持ってくるようにしているので、例えば 9 割方 [cci]Pictures/けものフレンズ[/cci] に保存しているが、たまに [cci]Pictures[/cci] に保存しただけで履歴の順番が変わってしまう。履歴のソート順として参照回数順、辞書順などのオプションを実装する必要がある。

Save to #3

インストール手順を英語でも書いた。

Enjoy Kokoni!
https://appsweets.net/kokoni/index-en.html

同時に、basename を省略してアクセスしても、accept-language ヘッダの先頭が ja かどうかで適宜振り分けるようにしたのだが。Chrome の場合 [cci]$ LANGUAGE=en google-chrome[/cci] などとしても accept-language ヘッダの内容は変化しない、Firefox の場合は逆に言語設定で ja を先頭にしているにかかわらず accept-language には ja が追加されない…など、それぞれがなんか妙な動きをして困る。

困るといえば、テストのためにいろんな画像を虹裏から落としているのだが、割と同じ画像を保存してしまうことがある。こういう場合に Kokoni 側で面倒を見るべきだろうか。でもそれはそれで面倒くさいなあ…。

やっぱりやめた。

$ fdupes -fdN ~/ピクチャ/けものフレンズ

すればいいだけのことだ。

それにしてもこの手の拡張が Chrome にも欲しいという意見は2012年頃からあったのに、なんで誰も作らなかったんだろう…。

Save to #2

Chrome Web Storeに公開した。

それから、詳しいインストールの仕方を書いたページを作った。しかし絶対パスや相対パスというむつかしいこんぴゅーたー用語が万人に通じるのか若干不安がある。Chrome の filesystem まわりの API がフルパスの扱いに関してぶっ壊れっぱなしで、スクリプト側で良きに計らえないのが悪い。

Save to

Firefox に Save File to という素敵な拡張がある。これはリンクや、メディアファイルに対するコンテキストメニューにホームディレクトリの任意のディレクトリツリーを展開し、選択したメニュー項目のパスへ直接ダウンロードする機能を提供する。つまり、とりあえず ~/Downloads に落として、落とし終わったら適当なところに移動…という手間を省ける。

似たようなものは Chrome にはないのだろうかと探してみたのだが、ないようなので作った。

本家には保存ダイアログにまで割り込んだり、サブメニューを左右交互に展開させるような芸の細かいオプションがあるが、残念ながら Chrome の拡張にそこまでの自由度はないので勝手に移植物としてはかなり不完全。

The Space

qeema 内でデグレードしてた箇所があったのを修正。Chrome で、IME がオンの状態でスペースキーを押すと(設定によっては)いわゆる全角スペース(U+3000、IDEOGRAPHIC SPACE)が直接入力されるわけだが、それが wasavi に正しく伝達されなくなっていた。

この全角スペースの直接入力に対して Chrome が発生させるキーボードイベントは実におかしい。

  • まず keydown イベントが発生する。この時、keyCode は 229 で入力されつつある文字が全角スペースだという判断には使えない
  • 各種 Composition Events は一切発生しない
  • input イベントが発生する。このイベントは単に通知のためのもので、何が入力されたかという情報は与えてくれない

という感じに実におかしい。Firefox であれば正しく Composition Events が発生する。IME まわりは Firefox の方が Chrome よりずっと真っ当だ。

対応策として、input 内でなんとかするしかなく、実際そうしている。keydown でその時点での対象の要素の値を保存しておき、input で差分を取ってイベントを生成させる。デグレードしたのはその処理が正しく呼ばれていなかった。

Testing with selenium javascript binding #3

テストを開始しても、Chrome は起動するもののテストページへのナビゲーションが発生せず、そのままタイムアウトで失敗してしまうことがたまにある。これの原因がよくわからない。とりあえずエラーメッセージとしては “unable to discover open pages” というものが返される。

原因はよくわからない。テスト用とは別の常用の Chrome を起動している状態でテストを開始すると、そういう状況が発生することがあるが、しないこともある。常用の Chrome を落とした状態でも発生することがある。また youtube とかの動画を再生中だと発生する確率が上がるような気がしないでもない。要するに chromedriver が Chrome を起動するものの、複数ある Chrome のうち自分が起動させたものを見失うことがある、というような感じなのだが……そんなわけあるかいな。

上記のエラーメッセージでググっても特にこれだというものもない。謎。

ちなみに Linux 版の Chrome で UI のロケールを任意のものにするには、[cci]LANGUAGE=en google-chrome[/cci] などとする。LANG でも LC_ALL でもなく、LANGUAGE。また、[cci]–lang[/cci] スイッチは効かない。

 * * *

というわけで、とりあえずすべての機能テストを java から javascript へ移植した。疲れた。次にこれを Windows 上の Firefox にて通してテストする。とその前に、Linux 上でも動かしてみよう。

ナウい Firefox で Selenium のテストをするには、geckodriver が必要なので、これをパスの通ったところに置いておく。


var options = new firefox.Options();
options.setProfile(profilePath);
result = new webdriver.Builder()
.withCapabilities(webdriver.Capabilities.firefox())
.setFirefoxOptions(options)
.build()

こんな感じで起動。これは既存のプロファイルを利用するような動作を意図しているが、どうも既存のプロファイルを /tmp あたりにまるごとコピーしてから起動するような感じがする。そのため実際に Firefox のウィンドウが表示されるまでは結構待たされる。

それから、webdriver.actions().sendKeys().perform() が未実装なのだそうでエラーになる。その代わり WebElement#sendKeys() を使う。geckodriver 自体が新しいプロジェクトなので、すべての想定された機能が実装されるまでにはもうちょっとかかる雰囲気。

 * * *

結局のところ wasavi でテストするには

  1. [cci]npm install -g selenium-webdriver[/cci]
  2. [cci]npm install -g chromedriver geckodriver operadriver[/cci]
  3. [cci]npm install -g mocha[/cci]
  4. [cci]npm install[/cci]

と入れて、[cci]make run-chrome[/cci] としてとりあえずテスト用プロファイルでもって起動し、開発者モードで wasavi を組み込み、ついでに dropbox などに wasavi からアクセスして認証を得ておき、ブラウザを閉じてから [cci]make test-chrome[/cci] とする……という感じ。ただし Firefox に関しては、過去の記事の通りオンザフライで WebExtensions ベースの拡張を登録するのが現在のところできないので、wasavi をビルドした上で xpi を登録したプロファイルを用意する必要がある。