夏休みの宿題「Ankiを捗らせるためのjsビューア」提出しました

AnkiDroidでひながいちにち遊ぶのに、ストレスを感じていました。以前の記事(市販のデータを用いてAnkiデッキを生成する方法 Epwing辞書編)の方法で辞書からデッキを生成したためカードのサイズが大きく、頻繁にスクロールのためにタッチ操作をしなければいけなかったためです。Ankiを可能な限り高速に単語とその語義を頭に叩き込むゲームとして考えたとき、このUIには改善の余地があると思いました。幸い、カードはHTMLそのものであり、JavaScriptを読みこませれば、カード閲覧時のUIはカスタマイズ可能です。そこで、いくばくかの時間を割いて、UIの改善を試みることにしました。この改善により学習効率が向上すれば、十分にペイすると考えたからです。

開発中のできごと

当初の方針

当初、DOMを走査してページ位置を割り出せるかな、と見込んでいました。可能なら、それを元に仮想的なページ位置を前後できればビューアが完成するはず…でした。

方針転換

DOMの走査は高コストで、かつ、要素によってはオフセット位置を取れなかったりしました。そこで、CSS3 Multiple Columnsを使って、カラム幅 = ウィンドウ幅に設定し、レンダリングの段階でページごとに描写されるようにしました。この方法だと、単にX方向のスクロール位置をウィンドウ幅ぶん前後させるだけで、ページの前後が実現できます。

生JSを諦める

ひとまず動く、というところまでは生JavaScriptを書きましたが、型がないことに耐えられず、途中でScala.jsに切り替えました。初めて触ったのですが、Scala.jsは思っていたより完成度が高く、十分実用になるのではという印象を持ちました。

AnkiDroidにプルリクを送る

  • Cookieが永続せず消える
  • ユーザーアクションなしでaudio/videoをloadできない

の2点が問題になったため、改善するためのパッチを書いて送りました。無事マージされたので、自前ビルド地獄に落ちずにすみました。Playストアからダウンロードできると楽でいいです。

できあがったもの

全体をスクロールするのではなく、ページ単位の戻り/送りでAnkiのカードを眺めるためのビューアです。シンプルなものですが、これで十分実用になります。

操作方法

操作 動作
左右スワイプ ページ戻り/送り
左右タップ ページ戻り/送り
左右ロングスワイプ チャプター戻り/送り
中央タップ 音声再生
タップ&ホールド 音声リピート再生

成果物

アプリケーションはScalaで記述されており、全部で900行ほど、レポジトリはrubyu/anki-iframe-viewerです。 gitsbtがインストールされている環境であれば、以下の手順でコンパイルできます。

$ git clone https://github.com/rubyu/anki-iframe-viewer
$ cd anki-iframe-viewer
$ sbt prod/dist

現時点で1.0が最新です。リリース一覧からコンパイル済みのjsファイル、cssファイルがダウンロードできます。

  • anki-iframe-viewer-opt.js

ビューア本体。このビューアはページを左右にスライドさせることでページ送りを実現しているので、以下のcssの適用が必要です。

  • anki-iframe-viewer.css

CSS3 multi-column layout をカードに適用するためのCSS#container内にコンテンツが入るようにすると、うまく表示されると思います。

anki-iframe-viewer.cssは標準的な1カラム構成のデザインです。タブレットなどでは、複数カラム構成のデザインのほうが見やすいかもしれません。例えば2カラム構成ではanki-iframe-viewer.cssに続けて、次のコードを適用してください:

#container {
  -webkit-column-width: 50vw;
}

注1: コンテンツの横のグラデーションは、このビューアには関係がない、現在どのあたりを読んでいるのかわかりやすくするためのハックです。適当なHTMLに対し、

<div class="outer">
  <div class="inner"> ... </div>
</div>
.outer {
  /* berry-juice from http://codepen.io/tumanova/pen/tkvmi?editors=0100 */
  background-image: linear-gradient(to bottom, #c5d4d7 6%, #d6b98d 34%, #c99262 57%, #8c5962 80%, #43577e 100%);
}
.inner {
  margin: 0 0.5rem 0 0;
  padding: 0 0.5rem 0 0.5rem;
  background-color: white;
}

などとすれば、最近のブラウザなら再現できるはずです。

注2: anki-iframe-viewerという名前のアプリケーションですが、 iframe内でしか動作しないわけではありません。後述するiframeデッキに移行する際に導入することを目指して開発を始めたため、この名前になりました。

制約事項

以下の制約は、私が必要としておらず、コスト的にサポートできなかった部分です。

  • 画面をタッチして、文字列を選択したりすることができない
  • 複数のaudio要素が見つかった場合、最初のものだけを再生してしまう

設定方法

Scala.jsにてApplicationを以下のように定義しています。

@JSExport("AnkiIframeViewerApp")
object App extends Logger {
...
  @JSExport def minLongSwipeSize(d: Double)                      = { userMinLongSwipeSize = Option(d); this }
  @JSExport def minSwipeSize(d: Double)                          = { userMinSwipeSize = Option(d); this }
  @JSExport def minLongTouchMillis(d: Double)                    = { userMinLongTouchMillis = Option(d); this }
  @JSExport def maxGestureMillis(d: Double)                      = { userMaxGestureMillis = Option(d); this }
  @JSExport def dispatcherDuplicateEventWindowMillis(d: Double)  = { userDispatcherDuplicateEventWindowMillis = Option(d); this }
  @JSExport def tapCenterRatio(d: Double)                        = { userTapCenterRatio = Option(d); this }
  @JSExport def autoPlayAudio(b: Boolean)                        = { userAutoPlayAudio = Option(b); this }
  @JSExport def holdReplayAudio(b: Boolean)                      = { userHoldReplayAudio = Option(b); this }
  @JSExport def audio(query: String)                             = { userAudioQueries += query; this }
  @JSExport def chapter(query: String)                           = { userChapterQueries += query; this }
...
  @JSExport
  def run(): Unit = {
...
  }
}

よって、カード自体は

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
<link rel="stylesheet" type="text/css" href="anki-iframe-viewer.css">
<script type="text/javascript" src="anki-iframe-viewer-opt.js"></script>
<script type="text/javascript">
  AnkiIframeViewerApp()
    .audio("#chapter-00 audio")
    .chapter("#chapter-01")
    .chapter("#chapter-02")
    .run();
</script>
</head>
<body>
<div id="container">
  <div id="chapter-00"> ... </div>
  <div id="chapter-01"> ... </div>
  <div id="chapter-02"> ... </div>
</div>
</body>
</html>

のようなHTMLにすべきです。

ここで、AnkiIframeViewerApp()audio()chapter()をコールして設定を行い、run()でアプリケーションを開始していることに注意してください。anki-iframe-viewer-opt.jsを読み込んだだけではアプリケーションは開始されません。

まずは適当なhtmlファイルを作り、ローカルサーバでビューアが動作することを確認してみてください。正しく動作すれば、それをAnkiに移植してみましょう。

Ankiへの導入方法

Ankiへの導入にはいくつかの問題があり、それらを回避しなければなりません。以下には問題とその対処法を記します。

注1: 何か問題が発生したとき、あるいは何も起こらなかったとき(動きさえしない!)に責任を負うことができないため、実際にデッキにこのビューアを組み込むことは推奨しませんが、なにかの役に立つこともあるだろう、ということで本稿を公開しています。ご注意を。お役に立てたなら嬉しいです。

注2: 2016/07/19現在、このビューアはAnkiDroidでのみ動作確認が取れています。Anki Mobileは不明。Anki Desktopでは動作しません。Anki Desktopについては、組み込んであるPyQt 4.8.4がちょー古いためです。ただし、PyQt 5.5への移行が進行中なので、そのうち動くようになるのではないかと思います。

AnkiDroidとその他の環境で併用したい

動作しない環境では、cssとjsファイルを削除すれば、従来通りのスワイプクロールUIに簡単にフォールバックできます。これは、将来的にこのビューアに問題が発生した場合も、この方法で簡単にフォールバックが行えるということを意味します。

カードのheadをカスタマイズできない

JavaScriptを使って改変できます。カードのテンプレートに、例えば以下のように記述します。

<script>
  var meta = document.createElement("meta");
  meta.setAttribute("name", "viewport");
  meta.setAttribute("content", "width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no");
  document.getElementsByTagName("head")[0].appendChild(meta);
</script>

Ankiのデッキサイズに100MBの制約がある

Collections on AnkiWeb are limited to 100MB, not including media.

Are there limits on file sizes on AnkiWeb? / Anki Ecosystem / Knowledge Base - Anki Support

これは実に絶望的な制約で、例えばCOCA60K(6万単語)コーパスからデッキを生成すると、平均して単語あたり1000文字ほどが上限になってしまいます。このための回避策として、カード自体をhtmlファイルに切り出して、カードからはそのhtmlをiframeとして参照するという方法をなんとか編み出しました。以下の手順で、既存のデッキに適用できます。

準備

まず、次の記事を参考に、データを処理する環境を準備してください: 市販のデータを用いてAnkiデッキを生成する方法 Epwing辞書編 - ruby-U's blog

次にhttps://github.com/rubyu/wok-scripts/archive/v0.2.zipをダウンロードして、同様に配置してください。

iframeデッキの構築

以下では、チュートリアルで生成したres2.tsvに手を加えて、カードのデータをhtmlファイルとして切り出します。

> java -jar wok-0.1.0.jar -f wok-scripts-0.2\col-detach.wok -v@str src=1,2,3 -v dst=1 -v@rawstr media=media res2.tsv > res3.tsv
> type res3.tsv
"apple" "<iframe src=""4CC39A56AE79DCFDA45E47D76C6C4D94.html""></iframe>"
"book"  "<iframe src=""1B764C2ED28D05BD3759E0ADAAF56557.html""></iframe>"
"car"   "<iframe src=""8D5A6E213A3F1BC101840112AABE2D0F.html""></iframe>"
> dir *.html /b media
4CC39A56AE79DCFDA45E47D76C6C4D94.html
1B764C2ED28D05BD3759E0ADAAF56557.html
8D5A6E213A3F1BC101840112AABE2D0F.html

stcに複数の列が指定されていることに注意してください。これらの列はまとめて一つのhtmlファイルに変換され、dstでそのhtmlを参照するiframeの出力先の列を指定しています。mediaに出力されたhtmlファイルは以下のようなフォーマットになっています。

<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<link rel="stylesheet" type="text/css" href="col-detach.css">
<script type="text/javascript" src="col-detach.js"></script>
</head>
<body>
<div id="detached">
  <div id="detached-00"> ... </div>
  <div id="detached-01"> ... </div>
  <div id="detached-02"> ... </div>
</div>
</body>
</html>
Ankiへの導入

この例の場合、iframeから読み込まれるhtmlは col-detach.jscol-detach.cssを読み込みます。必要であれば、 anki-iframe-viewer-opt.jsにビューアを起動するためのスクリプトを追記したものをcol-detach.jsanki-iframe-viewer.csscol-detach.cssとリネームし、collection.mediaに配置してください。

ひとつのディレクトリに大量のメディアファイルを置くと、メディアスキャンがバッテリードレインを起こすことがある

Anki/AnkiDroidではcollection.media内にファイルを配置することを推奨していますが、その数が膨大になると、パフォーマンス上の問題が発生します。アプリケーションの動作のみならず、端末自体のパフォーマンスが低下することもあるため、Epwingから生成したデッキには以下の手順を適用するといいかもしれません。

バッテリードレインの例:

参考: Issue 37199 - android - android.process.media draining battery at boot - Android Open Source Project - Issue Tracker - Google Project Hosting

tsvファイルにディレクトリ構造を適用する
> java -jar wok-0.1.0.jar -f wok-scripts-0.2\apply-directory-structure-to-tsv.wok -v idx=1 -v depth=2 -v@rawstr prefix=Foo res3.tsv > Foo.tsv
> type Foo.tsv
"apple" "<iframe src=""Foo/4/C/4CC39A56AE79DCFDA45E47D76C6C4D94.html""></iframe>"
"book"  "<iframe src=""Foo/1/B/1B764C2ED28D05BD3759E0ADAAF56557.html""></iframe>"
"car"   "<iframe src=""Foo/8/D/8D5A6E213A3F1BC101840112AABE2D0F.html""></iframe>"
htmlファイルにディレクトリ構造を適用する
> java -jar wok-0.1.0.jar -f wok-scripts-0.2\apply-directory-structure-to-html.wok -v depth=2 -v@rawstr target=media

mediaで指定されたディレクトリ内のhtmlファイルについて、head内にbaseタグを追加し、また、メディアファイル(mp3, png)のパスを書き換えます。 ここで、depthで指定された数だけparentへと遡るbaseタグが設定されることに注意してください。

mediaディレクトリ内の全てのファイルにディレクトリ構造を適用する
> java -jar wok-0.1.0.jar -f wok-scripts-0.2\apply-directory-structure-to-media.wok -v depth=2 -v@rawstr target=media
> dir /b media
0
1
2
...
> dir /b media\4
0
1
2
...
> dir *.html /b media\4\C
4CC39A56AE79DCFDA45E47D76C6C4D94.html

指定のdepthの、ファイル名のprefixから生成されたディレクトリ構造が適用されていることを確認してください。 問題がなければ、mediaディレクトリをFooapply-directory-structure-to-tsv.wokprefixで指定した値)にリネームして、これをcollection.mediaにコピーすれば手順は完了です。Foo.tsvをAnkiに読み込み、動作を確認してください。

この例の場合、iframeから読み込まれるhtmlは Foo/col-detach.jsFoo/col-detach.cssを参照します。必要であれば、 anki-iframe-viewer-opt.jsにビューアを起動するためのスクリプトを追記したものをcol-detach.jsanki-iframe-viewer.csscol-detach.cssとリネームし、collection.media/Fooに配置してください。 同様に、iframeから参照されるその他のリソースファイルが存在すれば、それらもcollection.media/Fooに配置してください。cssファイルから参照されるフォントなど、間接的な参照もこの場合に含まれることに注意してください。

大量のファイルを含むアーカイブファイルを扱えないファイラーがある

前述の手順で生成されたディレクトリをAndroid上にコピーするには、圧縮したあと、Android上でアーカイブファイルを解凍するという手順が簡単かつ高速なのですが、ESファイルエクスプローラーなど有名なアプリでも、大量のファイルを含むアーカイブファイルを扱えない場合があります。RARというアプリでは問題なく展開できるようなので、このような場合には試してみてください。

実際の例

私はCOCA60Kコーパスから、語義がEpwing辞書なデッキを作り、さらに前述の方法で語義をiframeに変換して、ディレクトリ構造を適用したものを実際に運用しています。以下に設定を記載します。

ノートタイプ

ノートタイプは「Front」「Back」の二つからなる、標準のノートタイプです。

テンプレート

表面のテンプレート
<div id="anki-front">{{Front}}</div>
書式
@font-face { font-family: _myfont00; src: url('_Lucida_Sans_Unicode.ttf'); }
@font-face { font-family: _myfont01; src: url('_yugothic.ttf'); }
@font-face { font-family: _myfont02; src: url('_HanaMinA.ttf'); }
@font-face { font-family: _myfont03; src: url('_HanaMinB.ttf'); }

html {
  font-size: 100%;
}
html body,
html body * {
  padding: 0;
  margin: 0;
}
body {
  overflow: hidden;
}
#outer {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
}
iframe {
  width: 100%;
  height: 100%;
  border: none;
}
#anki-front {
  margin: 1rem;
  display: block;
  font-size: 1rem;
  font-family: _myfont00, _myfont01, _myfont02, _myfont03, sans-serif, serif;
  text-align: left;
  line-height: 1.5rem;
  color: black;
  background-color: white;
}
.win #anki-front {
  font-size: 200%;
}
裏面のテンプレート
<script>
  document.createElement("meta");
  meta.setAttribute("name", "viewport");
  meta.setAttribute("content", "width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no");
  document.getElementsByTagName("head")[0].appendChild(meta);

  document.addEventListener("touchstart", function(event) {
    event.preventDefault();
  }, false);
  document.addEventListener("touchmove", function(event) {
    event.preventDefault();
  }, false);
  document.addEventListener("touchend", function(event) {
    event.preventDefault();
  }, false);
  document.addEventListener("DOMContentLoaded", function() {
    document.getElementsByTagName("iframe")[0].focus();
  }, false);
</script>
<div id="outer">{{Back}}</div>

バイスごとの設定

SO-03G
collection.media/COCA/col-detach.js

anki-iframe-viewer-opt.jsに以下を結合したもの。

AnkiIframeViewerApp()
  .audio("#detached-02 audio")
  .chapter("#detached-03")
  .chapter("#detached-04")
  .chapter("#detached-05")
  .chapter("#detached-06")
  .chapter("#detached-07")
  .chapter("#detached-08")
  .chapter("#detached-09")
  .chapter("#detached-10")
  .run();
collection.media/COCA/col-detach.css
@font-face { font-family: _myfont00; src: url('_Lucida_Sans_Unicode.ttf'); }
@font-face { font-family: _myfont01; src: url('_yugothic.ttf'); }
@font-face { font-family: _myfont02; src: url('_HanaMinA.ttf'); }
@font-face { font-family: _myfont03; src: url('_HanaMinB.ttf'); }

html {
  overflow: hidden;
  font-size: 2vmin;
}

html * {
  font-size: 1rem;
}

body {
  margin: 1rem 0 1rem 0;
  height: calc(100% - 2rem);
  font-family: _myfont00, _myfont01, _myfont02, _myfont03, sans-serif, serif;
  text-align: justify;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: subpixel–antialiased;
  -webkit-text-size-adjust: 100%;
  -webkit-user-select: none;
  cursor: default;
  line-height: 1.4;
  color: black;
  background-color: white;
}

#detached {
  height: 100%;
  -webkit-column-width: 100vw;
  -webkit-column-gap: 0;
}

#horizontal-bar {
  position: fixed;
  left: 0;
  bottom: 0.05rem;
  width: 100vw;
}
#horizontal-bar-icon1-A,
#horizontal-bar-icon1-B,
#horizontal-bar-icon1-C {
  visibility: hidden;
  position: absolute;
  right: 0.5rem;
  bottom: 0.15rem;
  width: 1rem;
  text-align: center;
  vertical-align: bottom;
  font-family: san-serif, selif;
  font-size: 0.5rem;
  line-height: 0.5;
  -webkit-filter: grayscale(100%) opacity(70%);
}
#horizontal-bar-progress-bar {
  position: absolute;
  left: 0.5rem;
  bottom: 0.35rem;
  width: calc(100vw - 2.5rem);
}
#horizontal-bar-progress-total {
  position: absolute;
  top: 0;
  width: 100%;
  border-bottom: 0.05rem solid #f0f0f0;
}
#horizontal-bar-progress-current {
  position: absolute;
  top: 0;
  width: 0;
  border-bottom: 0.05rem solid #505050;
}

#detached-00 {
  display: inline;
  float: left;
  margin: 0 auto 0 0.5rem;
  font-size: 1.2rem;
  line-height: 1;
}
#detached-01 {
  display: inline;
}
#detached-01 table {
  float: right;
  margin: 0 1rem 0.5rem 0.5rem;
  max-width: 50vw;
  border-collapse: collapse;
  border: 0;
}
#detached-01 table * {
  font-weight: normal;
  line-height: 1.1rem;
}
#detached-01 td {
  margin: 2rem 0 2rem 0;
  text-align: center;
  min-width: 10vw;
}
#detached-01 thead th {
  background-color: #f0f0f0;
  font-size: 0.4rem;
}
#detached-01 tbody td {
  font-size: 0.6rem;
}
#detached-01 tbody:nth-child(2n) td {
  background-color: #fefefe;
}
#detached-01 tbody:nth-child(2n+1) td {
  background-color: #f9f9f9;
}
#detached-02 {
  display: none;
}
#detached-03 .ebquery:first-child::before {
  content: "\A";
  white-space: pre;
}
#detached-03:not(:empty),
#detached-04:not(:empty),
#detached-05:not(:empty),
#detached-06:not(:empty),
#detached-07:not(:empty),
#detached-08:not(:empty),
#detached-09:not(:empty),
#detached-10:not(:empty) {
  /* berry-juice from http://codepen.io/tumanova/pen/tkvmi?editors=0100 */
  background-image: linear-gradient(to bottom, #c5d4d7 6%, #d6b98d 34%, #c99262 57%, #8c5962 80%, #43577e 100%);
}
#detached-03 .ebquery:last-child::after,
#detached-04 .ebquery:last-child::after,
#detached-05 .ebquery:last-child::after,
#detached-06 .ebquery:last-child::after,
#detached-07 .ebquery:last-child::after,
#detached-08 .ebquery:last-child::after,
#detached-09 .ebquery:last-child::after,
#detached-10 .ebquery:last-child::after {
  display: block;
  padding-bottom: 0.2rem;
  text-align: right;
  font-size: 0.8rem;
}
#detached-03 .ebquery:last-child::after {
  content: "【ランダムハウス英語辞典】";
}
#detached-04 .ebquery:last-child::after {
  content: "【研究社 新英和大辞典】";
}
#detached-05 .ebquery:last-child::after {
  content: "【リーダーズ・プラス】";
}
#detached-06 .ebquery:last-child::after {
  content: "【英辞郎】";
}
#detached-07 .ebquery:last-child::after {
  content: "【ロングマン現代英英辞典】";
}
#detached-08 .ebquery:last-child::after {
  content: "【オックスフォード現代英英辞典】";
}
#detached-09 .ebquery:last-child::after {
  content: "【斎藤和英大辞典】";
}
#detached-10 .ebquery:last-child::after {
  content: "【ロングマン現代英英辞典】";
}
#detached-03 .ebquery:last-child,
#detached-04 .ebquery:last-child,
#detached-05 .ebquery:last-child,
#detached-06 .ebquery:last-child,
#detached-07 .ebquery:last-child,
#detached-08 .ebquery:last-child,
#detached-09 .ebquery:last-child,
#detached-10 .ebquery:last-child {
  box-shadow: 0 -5rem 1rem -5rem #909090 inset;
}
.ebquery:first-child {
  padding-top: 0.5rem;
}
.ebquery:not(:last-child) {
  padding-bottom: 0.2rem;
}
.ebquery:not(:first-child) {
  margin-top: 0.1rem;
  padding-top: 0.3rem;
}
.ebquery {
  margin: 0 0.5rem 0 0;
  padding: 0 0.5rem 0 0.5rem;
  display: block;
  word-wrap: break-word;
  background-color: white;
}
.ebkw {
  font-weight: bold;
  background-color: #f0f0f0;
}
.ebsb {  vertical-align: sub;  }
.ebsp {  vertical-align: super;  }
.ebec {
  vertical-align: text-bottom;
  height: 1.1rem;
  padding-bottom: 0.25rem;
}
.ebkw,
.pos,
.sense1,
.sense2,
.sense3,
.LDOCE5 .sup {
  padding-left: 0.2rem;
  padding-right: 0.2rem;
  line-height: 1.7;
}
.SRD      .sense1 {
 padding-left: 0.6rem;
}
.SRD      .sense2,
.BODY     .sense1,
.KENE7J5  .sense1,
.KENE7J5  .sense2,
.KENE7J5  .sense3,
.KQNEWEJ6 .sense1,
.KQNEWEJ6 .sense2,
.KQNEWEJ6 .sense3,
.PLUS     .sense1,
.PLUS     .sense2,
.eijiro   .sense1,
.LDOCE5   .sense1,
.LDOCE5   .sense2,
.OALD7    .sense1 {
 padding-left: 1.2rem;
}
.SRD      .pos,
.KENE7J5  .pos,
.KQNEWEJ6 .pos,
.PLUS     .pos,
.eijiro   .pos,
.BODY     .pos {
 background-color: #e0ffe0;
}
.LDOCE5   .sup {
 background-color: #ffead5;
}
.SRD      .sense1,
.KENE7J5  .sense2,
.KQNEWEJ6 .sense1,
.KQNEWEJ6 .sense3,
.PLUS     .sense1,
.eijiro   .sense1,
.BODY     .sense1,
.LDOCE5   .sense1,
.OALD7    .sense1 {
 background-color: #ffe8e8;
}
.SRD      .sense2,
.KENE7J5  .sense3,
.KQNEWEJ6 .sense2,
.PLUS     .sense2,
.LDOCE5   .sense2 {
 background-color: #e8ffff;
}

.KENE7J5  .sense1 {
 background-color: #e8e8ff;
}
.KQNEWEJ6 .ebul,
.PLUS     .ebbo,
.PLUS     .ebul,
.PLUS     .ebit,
.eijiro   .ebbo,
.eijiro   .ebit,
.LDOCE5   .ebbo,
.OALD7    .ebbo,
.BODY     .ebbo {
  font-weight: bold;
}

html {
  font-size: 10.5px;
}
Nexus7
collection.media/COCA/col-detach.js

SO-03Gと同じ。

collection.media/COCA/col-detach.css

SO-03Gと以下だけ異なる。

html {
  font-size: 13.5px;
}

#detached {
  -webkit-column-width: 50vw;
}

#detached-00,
#detached-01 {
  display: block;
  width: 100%;
  margin-bottom: 0.2rem;
}
#detached-01 table {
  float: none;
  margin: 0 auto 0 auto;
}
#detached-03 .ebquery:first-child::before {
  content: "";
  white-space: pre;
}
PC
collection.media/COCA/col-detach.js

使用しないので空のファイルを配置する。

collection.media/COCA/col-detach.css

SO-03Gと以下だけ異なる。

html {
  overflow: scroll;
  font-size: 16px;
}