Chienomi

キーボードやマウスで快適に読めるブックリーダー/マンガビューワ Kolmics

開発::application

MComixというブックリーダーをご存知だろうか。

Linuxでは定番のブックリーダーで、選択肢の乏しいマンガビューワのひとつである。 そしてもちろん、私も愛用していた。

私が愛用しているために、LWMPのブックリーダー機能ではMComixのショートカットキーをリスペクトしたものになっていたりもした。

しかし、MComixは残念ながらアップデートによりショートカットキーの仕様が大きく変更され、左右へのページめくりなどが失われてしまった。

変わった部分はショートカットキーくらいのものだから別に使い続けることはできそうなものなのだが、実際使ってみるとかなり使いづらい。 私としてはちょっと無理かなぁという判断になった。

しかしそもそもが選択肢に乏しいLinuxのマンガビューワ事情。 色々試したけれどMComixの代替になりそうなものは何もなかった。 これならLWMPのほうがずっとマシだ。

ならば、LWMPのブックリーダー機能を切り離せばいいのではないか。 どうせなければ作るしかないのだから。

ちょうど私には気になっていたものがある。 Neutralinojsというプロジェクトで、HTMLとJavaScriptで構成されたクライアントウェブアプリケーションをデスクトップアプリにすることができるものだ。

LWMPからブックリーダー機能を切り出し、Neutralinojsを使ってデスクトップアプリにするプロジェクト、それがKolmicsだ。

LWMPのブックリーダー機能

LWMPはそもそもの話として私がベッドでスマホでPC内のメディアファイルを再生したいところから始まっていて、当初は音楽と動画のみのサポートだった。

が、MEGAの廃止とかも絡んで色々と拡張されていった結果、イメージビューワ、ブックリーダー、テキストビューワが追加された。

この中でもブックリーダーはかなり凝った実装になっていて、手間のかかったものだ。 基本的にHTMLの要素をそのまま使う方式のLWMPにあって、canvas要素を使って画像を並べているあたりからしてそうだ。

見開き表示と見開き表示中のランドスケープ画像の1枚表示をサポート、ページ送りも1ページ送りと2ページ送りを簡単にコントロールできるようになっており、日本式のRTL表示にも対応。

ある程度キーボードショートカットにも対応しているため、思った以上に使いやすいものになった。

これでマンガもベッドで読めるようになったのだが、そもそも電子書籍はダウンロードして手元で読めるものが少ないのでどうしても自炊本や同人誌等がメインになり、現実には出番は多くはない。

Kolmics

LWMPのブックリーダーは基本的にかなり快適に読める。 オプションが少ないというのはあるけれど、私の用途にはちゃんと合っている。

LWMPのブックリーダーをデスクトップアプリより優先して使おうと思わないという面はあるが、それはどちらかというとネットワーク経由だからロードが遅くてページ送りがもたつくという理由が大きい。 ローカルファイルを読むのであればだいぶ軽減されるので、localhostでLWMPというのもアリではある。

けれど、それとは別にファイルブラウザの出来という問題もある。 LWMPのファイルブラウザはクラウドドライブのものと同様、最低限のものであり、NemoやThunarみたいに快適ではない。

それにLWMPはブックリーダーに特化しているわけでもないから、快適性はもっと追求する余地がある。

このあたりを踏まえると、「MComixの代わりにLWMPをローカルで使う」はかなり後退した感があり、「LWMPをベースにブックリーダーに特化したデスクトップアプリを作る」には十分な動機がある。

その制作だが、そもそもベースとなるコードはほとんどがLWMPに存在するものなので、開発部分はNeutralinojsに合わせるためのものがほとんどである。 それも割とコード量は少なくて、Neutralinojsのロード部分くらいのものだ。

この部分はneuで作ったときにデフォルトで生えているものだが、逆に言うとこれしか残っていない。

/*
  Function to handle the window close event by gracefully exiting the Neutralino application.
*/
function onWindowClose() {
  Neutralino.app.exit()
}

// Initialize Neutralino
Neutralino.init()

// Register event listeners
Neutralino.events.on("windowClose", onWindowClose)

index.htmlにはロード部分が追加されるけれど、これも1行。

<script src="/js/neutralino.js"></script>

もちろん本当にそれだけで動くわけではなく、コマンドラインを処理するための機能は追加されている。

function stripArgs(args) {
  args.shift()
  let index = args.findIndex(i => (i[0] != "-" || i == "--"))
  if (args[index] == "--") { index++ }
  return (index === -1 || !args[index]) ? [] : args.slice(index)
}

Neutralinojsの機能を使う場合はneutralino.config.jsonnativeAllowListに入れないといけないことに注意が必要。 例えばこのfilesystem.readDirectory()とか

async function children(dir) {
  const dir_items = await Neutralino.filesystem.readDirectory(dir)
  const img_files = dir_items
    .filter(i => i.type === "FILE")
    .filter(i => {
      const fn = i.entry.toLowerCase()
      return IMG_EXTENSIONS.some(ext => fn.endsWith(ext))
    })
    .map(i => {
      const dir_prefix = dir.replace(/[^/\\]$/, "")
      return [dir_prefix, i.entry].join("/")
    })
  return img_files.map(i => i.replace(/.*[/\\]/, ""))
}

LWMPでは画像リストはファイルブラウザでディレクトリを読んだ時の応答に基づいて作っているものだ。しかし、Kolmicsではファイルブラウザ機能がないのでこのあたりの立ち上がりをNeutralinojsを使って独自に作っている。

それ以外にももちろん、ウェブのままにはできない部分もある。 典型的には画像のロードだ。

ウェブアプリケーションとしてはページはURLで取れる画像ファイルであることを前提にしているので、ファイルを読み込んで返さなければいけないNeutralinojsでは事情が異なる。

……と思って作り込んだのだが、なんとNeutralinojs、スペースを含むファイルパスが上手くよめないというとんでもない問題があった。 しかし一方で、ディレクトリをマウントしてwebサーバーとして配信する機能があり、それだけで上手く動作してしまった……

async function main() {
  const args = stripArgs([...NL_ARGS])
  const dir = await normalize_dir(args[0])
  currentState.imglist = await children(dir)
  try {
    await Neutralino.server.mount("/media", dir)
  } catch(e) {
    console.error(e)
  }
  show_bookreader()
}

なのでブックリーダー部分に関しては、本当にウェブアプリケーションがそのまま持ってこれた形になった。

Kolmicsの追加機能

ただ、機能的に変更されている部分もある。

例えば、MComixではCtrl+PgUpやCtrl+PgDnが1ページ送りのショートカットキーになっている。 しかし、ウェブブラウザだとタブ切り替えにひっかかってしまうので、LWMPではCtrlを伴わない形に変更、MComixのPgUp/PgDnでの通常のページ送りは廃止されていたのだが、Neutralinojsでアプリケーションにするのであれば問題ないというか、Ctrl+PgUp/PgDnも普通に使うことができるのでこのあたりのショートカットを追加。

フルスクリーンのショートカットはウェブブラウザならブラウザが処理するのだけど、Neutralinojsの場合この部分のハンドリングがないので追加。

さらに快適性を上げるため、LTR/RTL切り替えと見開き表示切り替えにもショートカットを設定。 こちらはMComixと同じd, mと、独自のs(spread), r(reverse)の2つを設定している。

また、マウスのホイール操作でもページ送りを設定。

これらのショートカットキーやマウス操作は別にLWMPにあっても困らないものだが、そこらへんが改めて追加されているのは、そもそもLWMPは「スマホで見るため」に作られているためにPCでの操作の作り込みが甘く、対してデスクトップアプリはPC前提なのでこのあたりを気にした、ということ。

プリロード

また、LWMPでは色々考慮すべき事項があることから採用されなかったプリロードが取り込まれていたりする。

img要素を作って捨てる方式だ。

async function prefetch(page_index) {
  [1,2].forEach(prefetch_offset => {
    const url = currentState.imglist[page_index + prefetch_offset]
    if (!currentState.prefetched.has(url)) {
      const img = document.createElement("img")
      img.src = "/media/" + encodeURIComponent(url)
    }
  })
}

参照を残さないことでなるべく負荷の小さい実装にしているが、高速性を重視するならデコードしたデータを全部持っておいてもらったほうがいい。

これが組み込まれた経緯としてはcanvasで描画するのだけど、canvasをクリアしてから画像を描画するまでは空になるから、ロードが間に合っていないとチラつく。 なのでこの時間を短縮したいということだ。

ブックリーダーで読む画像に巨大なものを使う人はあまりいないと思うが、軽くても先読みはある程度しておかないとちらつきは解消しない。 LWMPなどではどの程度アグレッシブに先読みするか、そしてどの程度強くキャッシュするか設定できるようにしてもいいかもしれない。

Kolmicsの実装は将来的にLWMPにバックポートされる可能性もあるので、まずはKolmicsからだろうか。

配布

結構悩まされたのが配布。

neu buildでビルドすると各プラットフォーム向けのバイナリができるのだが、これが単体では起動できない。 resources.neuが必要だからだ。

生成されたもの一式がひとつのディレクトリにあればコマンドとしては起動できる。

ポータブルバイナリを作るにはbuild-scriptが必要らしいが、そちらは説明があまりにも具体性を書いていてうまく動かなかったし、私が希望するものとはちょっと違うようでもあった。

なのでおとなしく「バイナリ一式」方式を採用した。

Linuxの場合はコマンドで起動にもできるが、.desktopベースで用意したほうが良い。

.desktopはGUI上のアプリの定義だと思って良い。 ただ実際は.desktopが実行ファイルに直接紐づいているわけではないので、.desktopに依存して動作するものは意図したように動作していないケースがそれなりにある。

まぁないよりはあったほうがなので、kolmics.desktopを用意する。

kolmics.desktopExecでコマンドを指定することができ、これはフルパスで指定する必要はない。 ただ前述の通り「バイナリ一式」で配布する必要があるため、コマンドそのものは別途必要。 そのコマンドも用意する。

Iconで指定するものはフルパスで画像ファイルを指定することもできるが、名前で指定したほうが良い。 アイコンの置き場所はユーザーローカルには

${XDG_DATA_HOME:-$HOME/.local/share}/icons/<icon_theme>/<resolution>/<icon_name>.<ext>

である。

今回はSVGアイコンを用意し(私が描いた)、${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/kolmics.svgとして配置する方式をとった。

install.bashはこれらのインストール処理を書いたものである。 珍しく今回はBash。

また、珍しいといえば、今回はインストールスクリプトでinstall(1)を使っている。 coreutilsにあるので、特に問題はないだろう。

Windowsはコマンドで起動するのは厳しいだろうからどうやって起動するかが問題になった。 ファイルパスをひとつだけ取る方式なので、関連付けか、もくしはSendTo。 レジストリをいじる関連付けよりはということで、SendToを使うようにしたが、このあたりは私は詳しくないのでGeminiに手伝ってもらった。 だからうまく動くかどうかはわからない。

MacについてもGeminiに聞いたのだが、取れる方法がどれもあまり現実的ではなさそうだった。 コマンドとして実行する分には割と簡単にできるそうだ。

私にはわからないし、Macをサポートしたい強い動機がないのて、Macのサポートを強化してほしい人が登場するまではお預けすることにした。

使い心地

非常に良い。

プリロードを入れる前はちらつくので若干微妙だったんだけれど、プリロードを入れたら相当快適になった。 私の使い方だとMComixと遜色ない。

やはりこのショートカットで操作できるのは便利。

ESCのショートカットをどうするかは考えようと思っている。 LWMPでは「ブックリーダーを閉じる」だが、Kolmicsでは何にも割り当てていない。

そして……

この記事を書いてから公開するまでの間に0.0.2が出たし、プリロード&キャッシュ戦略は選べるようになったし、リファクタリングしてLWMP/XXWMPにも反映したし、Escキーにも反応するようになった。

プリロードはKolmics 0.0.1で採用された読み捨てる方式のほか、Mapを使ってキャッシュする方式も採用。 bookreader.hide()が呼ばれたときに(引数でコントロールできるが)クリアするようにした。

また、一気読みも追加。いちいちビューワから設定しないといけないのは面倒かもしれないけれど、一気読みが重かったときに困らないように等も考えてこれがバランスが良いかなと思っている。

0.0.2のリファクタリングではブックリーダー関連のものをbookreaderオブジェクトに入れた。 これに関しては諸説というか、個人的には「やらないでいいならやらないほうがいい」だと思う。

そもそもJavaScriptは「メソッドにおけるthisは定義ではなく呼び出しのレシーバに束縛される」という特徴がある。つまり、

bookreader_opt_jump(e) {
  const pagenum = document.getElementById("BookReaderPageNumber")
  const target_page = pagenum.value || 1
  bookreader_draw_page(Number(target_page) - 1)
  e.preventDefault()
}

というコードであれば、

document.getElementById("BookReaderPageJump").addEventListener("click", bookreader_opt_jump)

とそのままコールバック関数として利用できる。 ところが、

const bookreader = {
  opt_jump(e) {
    const pagenum = document.getElementById("BookReaderPageNumber")
    const target_page = pagenum.value || 1
    this.draw(Number(target_page) - 1)
    e.preventDefault()
  }
}

とすると、そのままコールバック関数にするとthisbookreaderを指さなくなってしまうので、

document.getElementById("BookReaderPageJump").addEventListener("click", e => {bookreader.opt_jump(e)})

と書く必要がある。

また、現代では基本的に微々たるものすぎて気にしないでいいところではあるのだけど、チェーンの解決はJavaScriptではやや重いものなので、パフォーマンス的にはフラットに書いたほうが有利でもある。

ただ、リファクタリングした上でブックリーダーのコードは293行もあって、フラットにすると相当見通しが悪い。

const bookreader = {
}

と書いておけば中のコードはエディタ上で畳めるし、bookreader自体10文字もあるので全体的に名前が長くなりがちということを考えると、「コードを整頓するために」オブジェクトに入れてしまいたい気持ちになる。 なんなら、ブックリーダー関係だけファイルを分けてもいいくらい。

Kolmicsは機能がブックリーダーしかないからまだいいのだけど、LWMPは962行中の292行を占めているので、わかり易さのために整頓したい。

後々の共通化のしやすさを考えてもここはbookreaderオブジェクトに入れる形にした。