読者です 読者をやめる 読者になる 読者になる

フニゲの開発日記

Electronとか...

CSNFの詳細について

 ルートフォルダの下にstory.json、各ページのデータはページ毎にページ番号のついたディレクトリに記録されている。

1
├── 1
│   ├── ly_d0
│   ├── ly_f0_b
│   ├── ly_f0_s
│   ├── ly_t0_b
│   ├── ly_t0_bt
│   ├── ly_t0_t
│   ├── ly_t1_b
│   ├── ly_t1_bt
│   ├── ly_t1_t
│   └── thumb
└── story.json

 

{"body":{
  "finishing_id":6, // 不明
  "sheet_id":3, // 用紙テンプレートのID。B4=3, A6=2など。
  "sheet_size":[257,364], // 用紙サイズ
  "serial_id":2, // 意味はわからんが常にpage_count+1
  "page_count":1, // ページ数
  "version":1, // 常に1
  "bind_right":true, // 右閉じ
  "finishing_size":[210,297], // 外枠サイズ(mm)
  "baseframe_id":6, // 不明。たぶんfunishing_idと等しい
  "author":"作者名",
  "story_id":1, // このIDがルートフォルダの名前
  "title":"タイトル名",
  "pageinfo_count":1, // pageinfoの要素数
  "startpage_right":false, // falseの時は左ページから始まる
  "edit_date":"20160928060003", // タイムスタンプ(年月日時分秒)
  "baseframe_size":[180,268], // 内枠サイズ(mm)
  "dpi":72, // ビットマップの解像度は sheet_size(mm) * 2.833
  "last_modify":1, // たぶん最後に編集したページ
  "pageinfo":[[0,1,1,0,0]], // 見開き毎の各ベージの配置(順序が変わることがあるため)
  "cover_col":2, // 表紙の色
  "layer_color":[ // 各レイヤーのRGB
    [-7950848,-16736256,-16777216],
    [-16738348,-16777056,-16777216],
    [-4259752,-6291456,-16777216],
    [-1918976,-6250496,-16777216]]
  }}

 レイヤーはdraw、frame、text、noteの4種類があって、それぞれ3色使えるので最大12枚。

  • draw(画像)

 ly_d[0-2] // 下描きのbitmap。
 width(2byte)+height(2byte)+width*height個のグレイスケールデータをzipで圧縮したもの。

  • frame(枠線)

 ly_f[0-2]_b // 枠線のbitmap。
 ly_f[0-2]_s // json

{"body":{
  "count":3,
  "shape":[
    // 2=rect, 4=lineWidth, x0, y0, x1, y1, x2, y2, x3, y3
    [2,4,147,584,391,584,391,746,147,746],

    // 1=line, 4=lineWidth, x0, y0, x1, y1
    [1,4,-1,-6,722,980],

    // 4=poly, 13=lineWidth, 5=numVertices, x0, y0, ...
    [4,13,5,291,837,184,796,235,721,314,726,331,807]
  ]}}

 

  • text(テキスト)

 ly_t[0-2]_bt // テキストのbitmap
 ly_t[0-2]_b // これもbitmap。
 テキストレイヤーにはフキダシの画像も描けるようになっているので、bitmapが2枚あるのだと思う。

 ly_t[0-2]_t // json

{"body":{
  "count":1,
  "shape":[
    // 5=text, x0, y0, 11=fontSize, 行揃え, boldとか, 縦書き, 文字列
    [5,121,474,11,0,0,true,"テスト\nテスト"]]}}

 

  • note(ノート)

 ly_n[0-2] // ノートのbitmap。

  • あと各ページにthumbというサムネール用のPNG画像がある。サイズは181x256ぐらい。


 まあいつか誰かがこの道を通るだろう。

tar-streamのテスト

Electron

 いま作っているのは、.csnfという超マイナーなフォーマットのエディタだ。これは簡単に言うと(あんまり簡単に言いたくないんだけど)JSONと画像データをtarで固めたもの。編集とか保存はおいおい作ることにして、まず既存のデータを読み込んで画面に表示するところまで作りたい。
 いろいろ素人なので作りながら勉強しようと思っているのだが、Electronでtarを扱うにはtar-streamというのを使うといいらしい。
http://stackoverflow.com/questions/13690613/how-to-untar-file-in-node-js

var tar = require('tar-stream');

var extract = tar.extract();
extract.on('entry', function(header, stream, callback) {
    // make directories or files depending on the header here...
    // call callback() when you're done with this entry
    console.log(header);
    callback();
});

extract.on('finish', function() {
    console.log('done!')
});

fs.createReadStream("hoge.csnf").pipe(extract);

 これに適当なサンプルを食わせると、だいたいこんなログが得られる。

{ name: '/1',
  mode: 1941,
  uid: 1941,
  gid: 1941,
  size: 1941,
  mtime: 1970-01-01T00:32:21.000Z,
  type: 'directory',
  ... }
{ name: '/1/1/ly_f0_b',
  mode: 2288,
  uid: 2288,
  gid: 2288,
  size: 2288,
  mtime: 1970-01-01T00:42:47.000Z,
  type: 'file',
  ... }
...
done!

 エントリのtypeがfileだった時だけmkdir -pしてstreamの内容をpipeでファイルに書き出せば、ディレクトリをテンポラリに復元できるだろう。

...
extract.on('entry', function(header, stream, callback) {
    // make directories or files depending on the header here...
    // call callback() when you're done with this entry
    if (header.type == 'file') {
        var filename = "." + header.name;
        var dirname = require('path').dirname(filename);
        mkdirp(dirname, function(err) {
            if (!err) {
                var writeStream = fs.createWriteStream(filename);
                stream.pipe(writeStream);
            }
        });
    }
    console.log(header);
    callback();
});
...

 これで終わり……だったら世の中美しいんだけどね。

 現実は泥臭いものだ。
 ディレクトリが複数あるデータを食わせるとなぜかエラーが出る。上のログをよく見ると、sizeがおかしいことに気づく。だいたいuidやgidとsizeが同じ値になるとか、常識で考えてありえないだろう。

 ありえないんだけど、まあtarは歴史の長いフォーマットだから、そういう実装もありなのかも知れない。実際コマンドラインのtarを使えば、警告も出さずに黙って展開してくれる。(外部の人間がデータに触らないように)悪意のあるトラップが仕込まれている可能性は否定できないが、ここは何も無いと信じて先に進む。

 tar-streamにパッチを当てるために、まずnode_modules/tar-streamの内容をソースコードディレクトリにコピーする。

> mkdir src/csnf-stream
> cp node_modules/tar-stream/*.js src/csnf-stream

 それから、ディレクトリを展開する時は、ヘッダを無視してサイズを0にするようにコードを修正。

// src/csnf-stream/header.js

...
// to support old tar versions that use trailing / to indicate dirs
if (typeflag === 0 && name && name[name.length - 1] === '/') typeflag = 5

// dir size of CSNF is always wrong. just ignore them...
if (typeflag == 5) size = 0; // この1行を追加
    
var c = cksum(buf)
...

tar−streamの代わりにこのモジュールをrequireして使えばいい。

//var tar = require('tar-stream');
var tar = require('./src/csnf-stream');

Pointer Eventsのテスト

 WindowsのIEではだいぶ前から使えたらしいけど、Chromeでも55から使えるようになったのでテストしてみる。
 Electron(今はv1.4.13)ではまだ使えないが、そんなに遠くない将来使えるようになるはずだ。

<html> 
<head>

<script>
document.addEventListener("pointerdown", function(e) {
    console.log("down", e.x, e.y, e.pointerType, e.pressure);
});
document.addEventListener("pointerup", function(e) {
    console.log("up  ", e.x, e.y, e.pointerType, e.pressure);
});
document.addEventListener("pointermove", function(e) {
    console.log("move", e.x, e.y, e.pointerType, e.pressure);
    if (e.pressure > 0) {
        var canvas = document.getElementById("canvas");
        var ctx = canvas.getContext("2d");
        var x = e.x - canvas.offsetLeft;
        var y = e.y - canvas.offsetTop;
        var size = e.pressure * 30;

        ctx.beginPath();
        ctx.globalAlpha = e.pressure;
        ctx.fillRect(x, y, size, size);
    }
});
</script>
</head>

<body>
pointer event test
<br>
<canvas id="canvas" width=500 height=500 style="border:1px solid black">
</body>
</html>

 こんな感じでイベントが取れる。

f:id:funige:20161230064729p:plain

 これまでwacomのwebブラウザ用プラグインしかなかったのだが、これならHUIONでもUGEEでも筆圧が取れるはず。たぶん。

 現状では価格の安い中華タブが今後主流になるのは間違い無いだろう。個人的にはwacomにはほんと期待していて、多少高くてもいいから精度の高い液晶タブレットを作って欲しいと思うんだけど。いやまったく、どうして描いた場所と違う位置に線が引かれるんだろう。視差なんてカメラでユーザーの目の位置を追跡して、ドライバできっちり補正すればいいじゃないか。

ダイアログの表示とか

electron

 ダイアログの表示はDialogを使う。requireの書き方は古いドキュメントがあって迷うけど、

const {dialog} = require('electron');

 でいいらしい。
 使い方は公式のドキュメントを読むのがいちばんわかりやすいと思う。

 前回の続きでまずネイティブメニューにAboutを追加してみる。

function installMenu() {
    var menu = Menu.buildFromTemplate([
        {
            label: L('MyApp'),
            submenu: [
                { label: L('About MyApp'),
                  click: function() { about(); } // 追加
                },
                { label: L('Quit MyApp'),
                  ...

 about表示は後でちゃんと作ることにして、とりあえずバージョンだけ表示しておこう。バージョンはpackage.jsonに書いてあるから、fsで取得できる。

function about() {
    var json =  JSON.parse(require('fs').readFileSync('./package.json'));
    var str = json.name + " v" + json.version;
    str += "\n\nCopyright (c) 2016 funige All rights reserved."
    dialog.showMessageBox({ message: str });
}

f:id:funige:20161228172534p:plain

 次はファイル操作。
 ファイルの読み込みには dialog.showOpenDialog() を使う。

// main.js
function open() { // 「ファイルを開く」メニューを追加してこの関数を呼び出す
    if (win) {
        var params = {
              filter: [{name: 'Images', extensions: ['jpg', 'png']}],
              properties: ['openFile']
        };

        dialog.showOpenDialog(win, params, function(filenames) {
            win.webContents.executeJavaScript('load("' + filename + '")');
        });
    }
}

// myapp.js
function load(filename) {
    $("#image").attr("src", "file://" + filename);
};

 こんな感じで画像ビューアのできあがり。あとはNEOの時に書いたコードを使い回して、画像を拡大縮小したりスクロールバーでスクロールできるようにすればいいわけだ。

とりあえずネイティブメニュー

Electron
// main.js
app.on('ready', function () {
    createWindow();
    installMenu(); // 追加
});
...
function installMenu() {
    var menu = Menu.buildFromTemplate([
        {
            label: 'MyApp',
            submenu: [
                { label: 'About MyApp' },
                { label: 'Quit MyApp',
                  accelerator: 'Command+Q',
                  click: function() {
                      app.quit();
                  }}
            ]
        },
        {
            label: 'View',
            submenu: [
                { label: 'Toggle Status Bar',
                  accelerator: 'Command+Option+S',
                  click: function() {
                      if (win) {
                          win.webContents.executeJavaScript('toggleStatusBar();');
                      }
                  }}
            ]
        },
        ...
    ]);
    Menu.setApplicationMenu(menu);
};

//myapp.js
function toggleStatusBar() { $("#statusBar").toggle(); }

 Electronはメインプロセスとレンダラプロセスの2種類のプロセスがある。
 app.quit()みたいなメインプロセスのメソッドは直接呼び出せばいいし、レンダラプロセスのメソッド(上の例だとtoggleStatusBar())は、executeJavaScriptで呼び出すのが簡単だと思う。

 どうせすぐにipcでプロセス間通信することになるんだけど。

 メニューなんだから多言語化もやりたいところ。何か正式なやり方があるような気もするけど、とにかく簡単に。

// main.js
function getLocaleFunc() {
    var dic = {
        "ja": {
            "About MyApp": "MyApp について",
            "Quit MyApp": "MyApp を終了",
            "View": "表示",
            "Toggle Status Bar": "ステータスバー",
            ...
        },
        ...
    };
    
    var locale = app.getLocale();
    for (var x in dic) {
        if (locale.indexOf(x) == 0) {
            return function(str) { return dic[x][str] || str };
        }
    }
    return function(str) { return str };
}

var L = getLocaleFunc();

 こんな感じで翻訳用の関数を作っておいて、文字列をL()で囲っておくとか、そんな感じでいいのではないかと。

もうプログラムを書くことも無いだろうと思っていたのだが

 ちょっと作りたいツールができたので、今はelectronとかいじってます。
 javaScriptの知識だけで何でも簡単に作れてしまうので、これは便利ですね。

 練習を兼ねて「PaintBBS NEO」というのを作りました。昔よく使ったお絵描きアプレットhtml5製クローンです。

 勉強にはなったのですが、ちょっと深入りしすぎたような気がします。こんなので誰が喜ぶんだか。同じ苦労をするなら、もっと多くの人の役に立つような(利益もそれなりに出るような)仕事をするべきです。
 
 利益をそれなりに出すことを考えると、ソースを全部公開するというわけにはいかないと思うのですが、誰も見ていないとすぐにモチベーションが落ちて、開発がだらだら遅れてしまうのが個人開発者の辛いところ。

 そういうわけで、今回は毎日ブログを書いて、成果を小出しにしていく感じで行こうと思っています。

やっぱり絵の仕事は切り離し

 絵の話は別の名前でやることにしますね。
 すぐ気が変わるのは申し訳ないんだけど、まあ検索で飛んでくる人にも悪いし。
 あんまり相乗効果もなさそうなので……