iPhoneのUIWebViewではてなキーワードを高速置換

blog.yatsu.info: CocoaでTxを使うためのObjcラッパー tx-objc-wrapper で書いたtx-objc-wrapperにはもうひとつHatenaKeywordsというサンプルプログラムがあります。

これはiPhone用のシンプルなブラウザアプリで、開いたWebページのすべてのはてなキーワードをリンクに置換するものです。

このアプリをビルドするには、tx-objc-wrapperディレクトリで以下のようにはてなキーワードCSVをダウンロードします。

% wget http://d.hatena.ne.jp/images/keyword/keywordlist_furigana.csv

そして HatenaKeywords/HatenaKeywords.xcodeproj をXcodeでビルドします。

起動するとUIWebViewでWikipedia日本語版を読み込み、以下のようにキーワードを置換します。緑色がはてなキーワードへのリンクです。


iPhone 3G実機で26万語からTxを構築すると90秒くらいかかります。ラッパーのコードがNSStringへの変換などを含んでいて、その分遅いかもしれません。後でチューニングしようと思います。

2回目に起動すると、前回構築したTxを読み込みます。

以下が実装の主な部分です。

- (void)webViewDidFinishLoad:(UIWebView *)aWebView {
  NSError *error = nil;

  // get the original HTML
  NSString *html = [aWebView stringByEvaluatingJavaScriptFromString:@"document.body.innerHTML;"];
  DDXMLDocument *doc =
    [[DDXMLDocument alloc] initWithHTMLString:html
                      options:HTML_PARSE_NOWARNING | HTML_PARSE_NOERROR
                      error:&error];

  // find text nodes that are not included in a link
  NSArray *nodes = [doc nodesForXPath:@"//*[name(.)!='a']/text()"
                  error:&error];
  for (DDXMLNode *node in nodes) {
    [node setStringValue:[txo replace:[node stringValue]]];
  }

  // generate HTML from DDXMLDocument
  html = [[[NSString alloc] initWithData:[doc XMLData]
                  encoding:NSUTF8StringEncoding]
      autorelease];

  // replace '[[[word]]]' with 'word'
  NSString *linkStr = [NSString stringWithFormat:@"$1"];
  html = [html stringByReplacingOccurrencesOfRegex:@"\\[\\[\\[([^\\]]*)\\]\\]\\]"
                      withString:linkStr];

  // escape quotation marks
  html = [html stringByReplacingOccurrencesOfString:@"'" withString:@"\\'"];

  // make it one line
  html = [html stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];

  // execute the JavaScript code
  NSString *js = [NSString stringWithFormat:@"document.body.innerHTML = '%@';", html];
  [aWebView stringByEvaluatingJavaScriptFromString:js];
}

このメソッドはUIWebViewの読み込みが終わったときに呼び出され、以下を行います。

  1. JavaScriptを実行しHTML(document.body.innerHTML)を取得する
  2. XPathでリンクの外のテキストノードを取得する
    (KissXML+HTMLを使用)
  3. [Txo replace:] ではてなキーワードを [[[キーワード]]] に置換し、ノードにセット
  4. DDXMLDocumentからHTMLを生成
  5. 正規表現で [[[キーワード]]] をリンクに置換
    (RegexKitLiteを使用)
  6. HTML文字列のクォーテーションマークをエスケープ
  7. HTML文字列を1行にまとめる
  8. JavaScriptでUIWebViewのHTMLを置き換える
いろいろ非効率的なところがあったり、エラー処理がなかったりするので、実際のアプリにするには書き換える必要があります。Webページによってはメモリが足りなくて落ちたり、その他のエラーが出たりすると思うので、ご注意ください。

CocoaでTxを使うためのObjcラッパー tx-objc-wrapper

CocoaでTxを使うためのObjective-Cラッパーを作りました。

tx-objc-wrapper

iPhone/iPod touch/iPadで大量の文字列を検出したり置換したりするのに使えます。

使い方

  1. ソースをダウンロード
    git clone http://github.com/yatsu/tx-objc-wrapper.git
  2. Txディレクトリ以下のファイルをXcodeプロジェクトに追加
  3. "Txo.h"をimport
  4. Txoのメソッドを呼び出す
API

構築/読み込み

// NSStringが入ったNSSetからTxを構築し、ファイルに保存する
+ (Txo *)build:(NSSet *)wordSet withFileName:(NSString *)fileName;

// Txをファイルから読み込む
+ (Txo *)read:(NSString *)fileName;

Txの単純なラッパー

- (NSUInteger)prefixSearch:(const char *)aString
                    length:(NSInteger)length
              resultLength:(NSInteger *)resultLength;

- (NSUInteger)commonPrefixSearch:(const char *)aString
                          length:(NSInteger)length
                         strings:(NSArray **)strings
                           txIds:(NSArray **)txIds
                           limit:(NSUInteger)limit;

- (NSUInteger)predictiveSearch:(const char *)aString
                        length:(NSInteger)length
                       strings:(NSArray **)strings
                         txIds:(NSArray **)txIds
                         limit:(NSUInteger)limit;

- (NSString *)resultLog;

- (NSString *)errorLog;

- (NSUInteger)keyNum;

Txo独自メソッド

以下のものはtx-rubyをマネして作りました。こちらのメソッドを呼びだすと便利でしょう。

- (NSInteger)longestPrefix:(NSString *)aString fromPosition:(NSInteger)position;

- (NSInteger)longestPrefix:(NSString *)aString;

- (NSArray *)searchPrefixes:(NSString *)aString forPosition:(NSInteger)position;

- (NSArray *)searchPrefixes:(NSString *)aString;

- (NSArray *)searchExpansions:(NSString *)aString forPosition:(NSInteger)position;

- (NSArray *)searchExpansions:(NSString *)aString;

- (void)scan:(NSString *)aString;

- (NSString *)replace:(NSString *)aString;

サンプルコード

tx-rubyのサンプルコードと同じことをしてみます。

Tx構築

NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *documentsDirectory = [paths objectAtIndex:0];
NSString *txPath = [NSString stringWithFormat:@"%@/%@", documentsDirectory, @"keywords.tx"];

NSMutableSet *wordSet = [[NSMutableSet alloc] init];
[wordSet addObject:@"foo"];
[wordSet addObject:@"ho"];
[wordSet addObject:@"hog"];
[wordSet addObject:@"hoga"];
[wordSet addObject:@"hoge"];
[wordSet addObject:@"hogeshi"];

Txo *txo = [Txo build:wordSet withFileName:txPath];

文字列検索

[txo longestPrefix:@"hogeeee"] // => 4 (which means "hoge" is in the index)

[txo searchPrefixes:@"hoge"] // => (ho, hog, hoge)

[txo searchExpansions:@"hog"] // => (hog, hoga, hoge, hogeshi)

部分文字列検索(scan)

TxoDelegateプロトコルのtxo:scannedString:atPosition:を実装します。

- (void)txo:(Txo *)aTxo scannedString:(NSString *)aString atPosition:(NSInteger)position {
  // 文字列と位置を含む配列をarrayに追加
  // (arrayはNSMutableArrayとしてクラスに定義しておく)
  [array addObject:[NSArray arrayWithObjects:aString,
                    [NSNumber numberWithInt:position], nil]];
}

scanを実行。

array = [[NSMutableArray alloc] init];
[txo scan:@"hogefugafoo"]; // => ((hoge, 0), (foo, 8))

部分文字列置換(replace)

TxoDelegateプロトコルのtxo:replace:を実装します。

- (NSString *)txo:(Txo *)aTxo replace:(NSString *)aString {
  return [aString uppercaseString]; // 大文字にして返す
}

replaceを実行。

[txo replace:@"hogefugafoo"] // => HOGEfugaFOO

以上のサンプルコードはIPhoneSimpleDemoというサンプルアプリに実装しているので、Xcodeでビルドしてお試しください。
実行すると以下のようになります。


C++ライブラリをObjective-Cでラップする方法は、Objective-C Wrappers for C++ Classes を参考にしました。

今後の予定

  • NSDictionary互換インターフェース
  • テストコードを書く
  • UTF8以外の文字コードに対応
  • ラッパーコードの効率的でないところを書き直し

Google Chart ToolsのGeomapで世界地図のドリルダウン

Google Chart ToolsGeomapで世界地図のドリルダウンを実装してみました。

サンプルサイト (2010-05-24 URL更新)



地図中の領域をクリックしてドリルダウンしていくことができます。上に戻るには左上の「Zoom Out」ボタンをクリックします。

本来は統計データを表示するものですが、地図から住所を入力したり、人やお店を探したりするインターフェースとしても使えそうです。

あまり深くまで行こうとするとエラーになりますので、実際に利用する場合は修正が必要です。

コードは以下のようになっています。Sinatraで実装しました。

require 'rubygems'
require 'sinatra'

helpers do
  include Rack::Utils
  alias_method :h, :escape_html

  def render_map(region)
    <<-END
    var data = new google.visualization.DataTable();
    data.addRows(7);
    data.addColumn('string', 'Country');
    data.addColumn('number', 'Number of people');
    data.setValue(0, 0, 'Germany');
    data.setValue(0, 1, 10);
    data.setValue(1, 0, 'United States');
    data.setValue(1, 1, 31);
    data.setValue(2, 0, 'Brazil');
    data.setValue(2, 1, 17);
    data.setValue(3, 0, 'Canada');
    data.setValue(3, 1, 8);
    data.setValue(4, 0, 'France');
    data.setValue(4, 1, 22);
    data.setValue(5, 0, 'RU');
    data.setValue(5, 1, 34);
    data.setValue(6, 0, 'Japan');
    data.setValue(6, 1, 22);
    var options = {};
    options['region'] = '#{h(region)}';
    options['dataMode'] = 'regions';
    options['showZoomOut'] = #{region == 'world' ? 'false' : 'true'};

    var container = document.getElementById('mapCanvas');
    var geomap = new google.visualization.GeoMap(container);
    geomap.draw(data, options);

    google.visualization.events.addListener(geomap, 'regionClick', function(props) {
      var region = props.region;
      new Ajax.Request(region, {
        method: 'get',
        asynchronous: true,
        evalScripts: true
      });
    });

    google.visualization.events.addListener(geomap, 'zoomOut', function() {
      new Ajax.Request('world', {
        method: 'get',
        asynchronous: true,
        evalScripts: true
      });
    });
    END
  end
end

get '/' do
  haml :index
end

get '/:region' do
  content_type('text/javascript')
  render_map(h(params[:region]))
end

__END__

@@ layout
%html
  %head
    %title Map Select
    %script{ :type => 'text/javascript', :src => 'http://www.google.com/jsapi' }
    %script{ :type => 'text/javascript', :src => 'js/prototype.js' }
    %script{ :type => 'text/javascript' } google.load('visualization', '1', {'packages': ['geomap']});
  %body
    #mapCanvas
    = yield

@@ index
%script{ :type => 'text/javascript' }= render_map('world')

やっていることは、Geomapを表示し、領域がクリックされたときにそのregionをAjaxでサーバーに送り、新しいregionでGeomapを表示しなおしているだけです。

動作させるには、これをmapselect.rbとして保存します。そして保存したディレクトリの下にpublic/jsというディレクトリを作成し、そこにprototype.jsを置きます。起動は

ruby mapselect.rb
とします。

時間があればRailsプラグインか何かにしてみようと思います。

IME開発者会議@Google食堂

今日はGoogle食堂にIME開発者が集まり、情報交換しました。GoogleIME, Social IME, SKKIME, ChaIME, AquaSKK, uim, MacUIMの開発者など総勢14名(たぶん)が参加し、ライトニングトーク大会になりました。

私の発表資料(余分なもの省略版)


InputHiliterについてはこちら。
http://inputhiliter.yatsu.info/

バージョン3.0から色を設定できるようになり、楽しいアプリになりました。
実用度はわかりませんが。



TreemapKit公開

iPhone/iPod touch/iPadでTreemapを表示するライブラリTreemapKitを公開しました。

http://github.com/yatsu/treemapkit



これはOval Plan社との共同プロジェクトでの成果の一部をオープンソースとして公開するものです。ライセンスはMITライセンスです。
ソースを拡張された場合、なるべくGitHub上で公開していただけるとありがたいです。一般的に有用な拡張は取り込んでいきたいと思います。

現時点では、値とラベルの配列を受け取り、内部で値に応じで2分割をネストしていく形になっています。独自のツリー構造を渡すことや、割り当てのアルゴリズムを変更することはできません。それらの機能は今後実装したいと思っています。

表示される四角は画面上では実際にはネストしていません。フラットな四角を並べた構造になっています。これは上のスクリーンキャストのように、データが変更されたときにアニメーションやエフェクトを適用するのに都合がよいからです。