Tuesday, February 1, 2011

JavascriptでLorem Ipsumを生成してみる (実験編)

プレースホルダーなテキストとしてよく使われるLorem ipsum dolor。

このLorem IpsumをJavascriptで生成してみましょう。

単語を抽出

Lorem Ipsumをつくるために、まず単語のリストが必要になります。適当なLorem Ipsum文から単語を抽出しましょう。

今回はlipsum.comで生成した文を利用しました。lipsum.comは1度に最大1万語を生成するので、5回ほど繰り返して出力し連結した5万語をもとに進めていきます。

// 適当なLorem Ipsumテキストをまるごとぶち込みます。
var lipsumWords = 'Lorem ipsum bra bra bra...';

では、この文字列から単語を抽出して配列にしましょう。

// このテキストを整形して単語に分割します。
lipsumWords = lipsumWords.replace( /[\n\r\t\.\,\;]/g , '' ) // 不要な文字を取り除いて、
                         .toLowerCase()                     // 全部小文字にして、
                         .split(' ');                       // スペースで分割します。

// 重複する単語を取り除くため一度連想配列のキーにします。
var lipsumWordsHash = {};
for( var i = 0; i < lipsumWords.length; i++ ){
    lipsumWordsHash[ lipsumWords[i]] = true;
}

// キーを配列に戻します。
lipsumWords = [];
for( var key in lipsumWordsHash ){
    lipsumWords.push(key);
}

これでLipsumの吐き出したユニークな単語の配列が抽出できます。今回の試行では5万語から575単語が抽出できました。

しかし試しにこれをソートして眺めてみると、もうひとつLipsumの隠れた仕様が見えてきます。

(途中省略)
..
eunam
eunulla
facilisi
facilisiinteger
facilisiquisque
facilisis
facilisisin
fames
..

“facilisi+...”など単語の連結が見られますね。これに限らず様々な語が連結されていました。
Lorem Ipsumジェネレーターの作成時にはこの仕様も採り入れたいですね。文法などあるかもしれませんが、面倒なので適当にくっつけることにします。

さて、くっついた単語が配列に入っていると、その組み合わせ次第でどんどん配列のサイズが大きくなります。辞書サイズはなるべくコンパクトにしておきたいので、こういったオーバーヘッドは減らしたいですね。
ということで上の「重複した単語を取り除く」実装を変更して、連結されていそうな単語を分割します。

var edited;
var recentWord = '';
var loop = 0;

// 編集済みフラグが消えるまでグルグル回す。
do {
    // 必ず2週以上回すため、1週目は編集済みフラグを立てておく。
    edited = !loop;
    // 2週目以降は予めソートする
    if(loop) lipsumWords.sort();
    // ハッシュのキーに変換
    var lipsumWordsHash = {};
    for( var iL = 0; iL < lipsumWords.length; iL++ ){
        // 2週目以降なら文字列の先頭を直前の値と比較して、同じであれば分離する。
        if( loop && recentWord.length > 3 && lipsumWords[iL].length > recentWord.length && lipsumWords[iL].substr(0,recentWord.length) == recentWord ){
            if( lipsumWords[iL].length-recentWord.length > 3 ){
                lipsumWordsHash[ lipsumWords[iL].substr(recentWord.length, lipsumWords[iL].length-recentWord.length)] = true;
            }
            // もう一度回すため編集済みフラグを立てる (分離後の単語がさらに分離できるかもしれないので)
            edited = true;
        } else {
            // 「直前の値」として記憶、およびハッシュのキーにする。
            recentWord = lipsumWords[iL];
            lipsumWordsHash[ lipsumWords[iL]] = true;
        }
    }
    // 配列に戻す
    lipsumWords = [];
    for( var key in lipsumWordsHash ){
        lipsumWords.push(key);
    }
    loop++;
} while( edited );

これで575語あったものが223語まで削減できました。今後はこの配列をベースに進めていきます。
だたし毎回この単語抽出はやっていられないので、この配列を再利用可能にするために文字列に再変換し、今後はそれをコードに埋め込むことにしましょう。

// 単語リストを文字列に変換
lipsumWords = lipsumWords.join();
// -> lorem,ipsum,dolor, ...


// 今後はこの単語リストを利用します
var lipsumWords = 'lorem,ipsum,dolor, ...';
lipsumWords = lipsumWords.split(',');

参考までに今回抽出した単語223語のリストを掲載しておきます: words.txt (1.7kB)

生成

では、上で作成した単語リストから実際にLorem Ipsumを生成しましょう。

乱数生成

単語をランダムに抽出するために乱数を用います。
しかし乱数生成+必要な数値の掛け算+整数に丸めるといったコードを毎度書くのも面倒なので、ラッパー関数に包んでしまいましょう。

// 乱数生成
// rand( int <max>, bool <realize> ) -> int or float
/*--------------------------------------
int <max>
  -> 0~<max> の範囲で乱数を生成します。
bool <realize>
  -> trueなら実数、そうでなければ整数を返します。
--------------------------------------*/
var rand = function( max, realize ){
    if(!max) max = 1;
    if(!realize) max++;
    var value = Math.random()*max;
    if(!realize) value = Math.floor(value);
    return value;
};

// ファジー化
// fuzzify( int <value>, float <fuzziness> ) -> int
/*--------------------------------------
int <value>
  -> 曖昧にしたい値を指定します。
float <fuzziness>
  -> 曖昧にする範囲を0~1の実数で指定します。
     例えば<value>が100、<fuzziness>が0.2であれば、80-120の範囲でランダムな整数を返します。
--------------------------------------*/
var fuzzify = function( value, fuzziness ){
    return Math.round( value*( 1-fuzziness + rand( fuzziness*2, true )));
};

上記ではビルトイン関数のMath.random()を利用しました。0から1未満のランダムな実数を返す関数ですね。

しかしこの出力される乱数の質ってどうなんでしょう? 乱数には質の良い乱数と悪い乱数があるそうです。
そうでなくともブラウザごとに出力される数値の精度が異なるくらいですから、あまり信用はできないでしょう。

今回のLorem Ipsum生成にそれほど乱数の質を求める必要はないですが、ついでですから異なる乱数にも対応してみましょう。
今回はメルセンヌ・ツイスタのJavascript実装版Mersenne Twister in JavaScriptを利用します。さきほどの乱数生成関数を修正しましょう。

// 乱数生成
// rand( int <max>, bool <realize> ) -> int or float
/*--------------------------------------
int <max>
  -> 0~<max> の範囲で乱数を生成します。
bool <realize>
  -> trueなら実数、そうでなければ整数を返します。
--------------------------------------*/
var rand = ( function(){
    var mt;
    if(typeof MersenneTwister != 'undefined') mt = new MersenneTwister();
    return function( max, point ){
        if(!max) max = 1;
        if(!point) max++;
        var value = ( mt ? mt.next() : Math.random())*max;
        if(!point) value = Math.floor(value);
        return value;
    }
})();

mt.jsがあらかじめロードされていればメルセンヌ・ツイスタを利用します。なければ標準のMath.random();を利用します。

単語の生成

まずは単語を生成しましょう。

単に単語を選択するだけでなく、先に述べたような単語の連結を適当な確率で行う必要があります。

// 単語を生成
// generateWord([ object <options> ]) -> String
/*--------------------------------------
object <options>
  -> {
       wordAppendingFrequency: (int) 単語を結合する頻度
     }
--------------------------------------*/
var generateWord = function(options){
    if( !lipsumWords || !lipsumWords.length ) return null;
    if(!options) options = {};
    var word = '';
    // 単語を連結する頻度
    var appendFreq = options.wordAppendingFrequency;
    if( appendFreq==undefined ) appendFreq = 10;
    // ランダムに複数の単語を結合する
    do {
        // 単語をランダムに選択
        word += lipsumWords[ rand(lipsumWords.length-1)];
    } while( appendFreq && rand(appendFreq) == 0 );
    return word;
};
文の生成

次は複数の単語から文を形成しましょう。

文の先頭は大文字、文末は.で締めて、適当な頻度で文中に,;を挟みます。

// 文を生成
// generateSentence( int <words> [, object <options> ]) -> String
/*--------------------------------------
int <words>
  -> 出力する単語の数
object <options>
  -> {
       fuzziness: (float) 文の数・単語数を曖昧にする,
       startWithLoremIpsumDolor: (bool) falseにすると文の先頭が "Lorem Ipsum Dolor..." にならない,
       commaAppendingFrequency: (int) コンマ等を加える頻度,
       * その他generateWordのオプションを継承
     }
--------------------------------------*/
var generateSentence = function( words, options ){
    if(!options) options = {};
    var sentence = [];
    var startFrom = 0;
    // 単語の数を曖昧にする
    var fuzziness = options.fuzziness==undefined ? .5 : options.fuzziness;
    if(fuzziness) words = fuzzify( words, fuzziness );
    // Lorem ipsum dolorで始めないことを指定されていない場合
    if( options.startWithLoremIpsumDolor || options.startWithLoremIpsumDolor==undefined ){
        sentence = ('Lorem ipsum dolor sit amet').split(' ');
        // 単語数が5以下ならそれだけ返して終了
        if( words <= 5 ){
            sentence.length = words;
            if( sentence.length ) return sentence.join(' ') +'.';
            return '';
        }
        sentence[4] = 'amet,';
        startFrom = 5;
    }
    // コンマやセミコロンを挟む確率
    var commaFreq = options.commaAppendingFrequency;
    if( commaFreq==undefined ) commaFreq = 20;
    // 必要な単語数分グルグルまわす
    for( var i = startFrom; i <= words; i++ ){
        var word = generateWord(options);
        // 先頭を大文字化
        if( i==0 ) word = word.charAt(0).toUpperCase() + word.substr(1);
        // コンマやセミコロンを挟む
        if( i!=words && commaFreq && rand(commaFreq)==0 ) word += (',;').charAt(rand(.5));
        sentence[i] = word;
    }
    // 結合して文にし出力
    if( sentence.length ) return sentence.join(' ') +'.';
    else return '';
}
段落の生成

文をつなげた段落も必要になります。

// 段落を生成
// generateParagraph( int <sentences> [, object <options> ]) -> String or Array
/*--------------------------------------
int <sentence>
  -> 出力する文の数
object <options>
  -> {
       words: (int) 各文の単語数,
       joinSentence: (string or false) 段落を結合する文字列を指定; falseを指定すると結合せずに配列を返す,
       * その他generateSentenceのオプションを継承
     }
--------------------------------------*/
var generateParagraph = function( sentences, options ){
    if(!options) options = {};
    var paragraph = [];
    var fuzziness = options.fuzziness==undefined ? .5 : options.fuzziness;
    if(fuzziness) sentences = fuzzify( sentences, fuzziness );
    for( var i = 0; i <= sentences; i++ ){
        // 最初以外はstartWithLoremIpsumDolorを無効にする
        if( i!=0 ) options.startWithLoremIpsumDolor = false;
        paragraph.push( generateSentence( options.words || 10, options ));
    }
    // 結合
    if(options.joinSentence!==false){
        return paragraph.join( options.joinSentence!=undefined ? options.joinSentence : '' );
    }
    return paragraph;
}
文章の生成

段落を結合して文章を作りましょう。

// 文章を生成
// generateComposition( int <paragraphs> [, object <options> ]) -> String or Array
/*--------------------------------------
int <paragraphs>
  -> 出力する段落の数
object <options>
  -> {
       sentences: (int) 各段落の文数,
       joinParagraph: (string or false) 段落を結合する文字列を指定; falseを指定すると結合せずに配列を返す,
       * その他generateParagraphのオプションを継承
     }
--------------------------------------*/
var generateComposition = function( paragraphs, options ){
    if(!options) options = {};
    var composition = [];
    var fuzziness = options.fuzziness==undefined ? .5 : options.fuzziness;
    if(fuzziness) paragraphs = fuzzify( paragraphs, fuzziness );
    for( var i = 0; i <= paragraphs; i++ ){
        if( i!=0 ) options.startWithLoremIpsumDolor = false;
        composition.push( generateParagraph( options.sentences || 10, options ));
    }
    if(options.joinParagraph!==false){
        return composition.join( options.joinParagraph!=undefined ? options.joinParagraph : "\n\n" );
    }
    return composition;
}

これで準備は整いました。

実行

テストしてみます。

段落をひとつ生成
var result = generateParagraph( 5 );

document.getElementById('result').innerHTML = '<p>'+ result +'</p>';
すべてのオプションを指定
var result = generateComposition( 5, {
    fuzziness: .5,
    sentences: 10,
    words: 10,
    commaAppendingFrequency: 20,
    wordAppendingFrequency: 10,
    startWithLoremIpsumDolor: true,
    joinSentence: ' ',
    joinParagraph: false
});

document.getElementById('result').innerHTML = 
    '<p>'+ result.join('</p><p>') +'</p>';

テストのサンプルを公開します: test.html

クラス化

次回の記事では使いやすいようにクラスにまとめていきます。

No comments:

Post a Comment