2010年2月18日木曜日

第15回 テキストを分解する

Twitterからはプレーンテキストで文字を取得しますが、
コメント中のURLなどはリンクにして、クリックでWebを開けるようにしたいですよね。

ということで今回はテキストを解析してリンクにします。

まずMVVM構造のどこでテキスト解析するかの検討です。
UIありきで作るのであれば、TextBoxのInlineに
Run(単純なテキスト)なりHyperLinkなりを追加していけば単純なのですが、
MVVMでModelがViewを意識しないという前提だとちょっと工夫が必要になります。

となるとViewでテキストを受け取って、その場で解析して、
分解したものをTextBoxにつっこんでいく方法が考えられますが、
NGワードが入っていたらフィルタリングして
でも、自分のidが本文中にあったらフィルタリングしないで、
といったことはModelかViewModelレベルでやりたいので、
Modelの部分で解析することにします。

ちなみに私は文字列解析は全然詳しくないので、
もしかしたらもっと簡単なやり方があるかもしれません

まずstringの元テキストを分解した後のデータを入れるクラスを作ります。
派生クラスは略。


 public enum TextComponentType {
  Plain,
  UserName,
  Url,
  Tag
 }

 /// <summary>
 /// テキストを意味のある単位で分解したパーツ
 /// ※例:URL、ID、ハッシュタグ、通常の文章
 /// </summary>
 public abstract class TextComponent {
  abstract public TextComponentType Type { get; }
  public string Text { get; set; }
 }


次に正規表現を利用してテキストを分割します。
以下のようなサポートクラスを作ります。

動作の簡単なイメージとしては
1.最初の文字列
"こんにちは。@yuki1090です。正規表現についてはhttp://www.google.comで検索してください"

2.Idで分割(IDについてSplitStepメソッドを適用)
1の文字列を入力として
"こんにちは。"
"@yuki1090"
"です。正規表現についてはhttp://www.google.comで検索してください"
の3つを出力する

3.URLで分割(URLについてSplitStepメソッドを適用)
2の結果の3つの文字列を入力として

"こんにちは。"
"@yuki1090"
"です。正規表現については"
"http://www.google.com"
"で検索してください"
の5つの文字列を出力する




  /// <summary>
  /// 分割の1ステップを表すクラス(1つの正規表現に対する一致を探して分割)
  /// </summary>
  private class SplitStep {
   private Regex _regex;
   private string _pattern;
   private Func<string, TextComponent> _generator;

   /// <summary>
   /// コンストラクタ
   /// </summary>
   /// <param name="pattern">検出するための正規表現</param>
   /// <param name="generator">検出した文字列から新しいTextComponentを生成する関数</param>
   public SplitStep(string pattern, Func<string, TextComponent> generator) {
    _pattern = pattern;
    _regex = new Regex(pattern, RegexOptions.Compiled);
    _generator = generator;
   }

   /// <summary>
   /// 正規表現に一致する部分を見つけて、分割した結果を返す
   /// IEnumerableの各アイテムごとに
   /// a. 入力がプレーンテキストの場合
   ///   正規表現に一致する部分とその前後の3つに分割して、3つのアイテムを返す
   ///   前後はプレーンテキストとして、一致部分は検出したタイプのクラスとして返す
   ///   ※先頭に一致した場合、2カ所に一致した場合などは返す個数が異なる
   /// b. 入力がプレーンテキスト以外(前段ですでに検出されたタイプ)の場合
   ///   そのまま返す
   /// </summary>
   /// <param name="source"></param>
   /// <returns></returns>
   public IEnumerable<TextComponent> Split(IEnumerable<TextComponent> source) {
    foreach (var item in source) {
     if (item.Type != TextComponentType.Plain) {
      //プレーンテキスト以外はそのまま返す
      yield return item;
     } else {
      var matches = _regex.Matches(item.Text);
      if (matches.Count > 0) {
       var index = 0;
       foreach (Match match in matches) {
        if (index < match.Index) {
         //一致より前の部分
         yield return new PlainText(item.Text.Substring(index, match.Index - index));
        }
        //一致した部分
        yield return _generator(match.Value);
        index = match.Index + match.Length;
       }
       if (index < item.Text.Length) {
        //一致より後ろの部分
        yield return new PlainText(item.Text.Substring(index));
       }
      } else {
       //一致がなかったらそのまま返す
       yield return item;
      }
     }
    }
   }
  }




このクラスを使っているのが以下の部分です。
正規表現の文字列を作って、
それぞれのパターンごとにクラスを作って、
テキストをすべてのクラスに処理させる感じです。



 class Splitter {
  #region Regex Pattern
  /// <summary>
  /// ユーザーID検出用パターン
  /// @xxxの@を除いた部分に一致
  /// </summary>
  const string _userNamePattern = @"(?<=@)([a-zA-Z0-9_]+)";

  /// <summary>
  /// URL検出用パターン
  /// </summary>
  const string _urlPattern = @"(https?://[a-zA-Z0-9$-_.+!*'(),#%]+)";

  /// <summary>
  /// ハッシュタグ検出用パターン
  /// #から空白まで
  /// </summary>
  const string _tagPattern = @"(#\S+)";
  #endregion

  #region private member
  private static SplitStep _userNameSplitter;
  private static SplitStep _urlSplitter;
  private static SplitStep _tagSplitter;
  #endregion

  static Splitter() {
   _userNameSplitter = new SplitStep(_userNamePattern, text => new UserName(text));
   _urlSplitter = new SplitStep(_urlPattern, text => new Url(text));
   _tagSplitter = new SplitStep(_tagPattern, text => new Tag(text));
  }

  /// <summary>
  /// 文字列をURLやIDごとに分割する
  /// </summary>
  /// <param name="text">元となる文字列</param>
  /// <returns>分割後のオブジェクト</returns>
  static public List<TextComponent> Split(string text) {
   //最初は1固まりのテキストから始めて
   //前段の分割結果(リスト)を後段の入力とする
   return
    _tagSplitter.Split(
     _urlSplitter.Split(
      _userNameSplitter.Split(
       new TextComponent[] {new PlainText(text)}
       )))
     .ToList<TextComponent>() ;
  }


この結果はViewModelが持っています。

  public List<TextComponent> TextComponents {
   get { return _item.TextComponents; }
  }


最後にこれをViewに反映する方法ですが、
IValueConverterを使います。
ViewにListを表示するように要求されたら
この変換クラスが呼び出されて、TextBlockに変換した結果が表示されます。



 [ValueConversion(typeof(List<TextComponent>), typeof(TextBlock))]
 class TextComponentsConverter : IValueConverter {
  #region IValueConverter メンバ

  public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) {

   var textblock = new TextBlock();
   textblock.TextWrapping = System.Windows.TextWrapping.Wrap;

   List<TextComponent> target = value as List<TextComponent>;
   foreach (var item in target) {
    switch (item.Type) {
     case TextComponentType.Plain:
      textblock.Inlines.Add(item.Text);
      break;
     case TextComponentType.UserName:
      var user = item as UserName;
      var link = new Hyperlink();
      link.Click += (sender, e) => Process.Start(user.WebUrl);
      link.Inlines.Add(item.Text);
      textblock.Inlines.Add(link);
      break;

・・・略
   return textblock;
  }

あとはTimelineViewのリソースにコンバータのインスタンスを作成して


 <UserControl.Resources>
  <CollectionViewSource  x:Key="AllItemsViewSource" Source="{Binding Path=AllItems}">

  <view:TextComponentsConverter x:Key="TextCompConverter"/>
 </UserControl.Resources>



以下のようににConverterを指定する


<ContentPresenter Content="{Binding Path=TextComponents, Converter={StaticResource TextCompConverter}}" />


ただ、今回の作りはModelを直接Viewが見ていることになるのかな?と悩んでいます。
それぞれにViewModel作るのはめんどくさいので
これは今のところこれでもいいかなと思っていますが。

ここまでのソースコード
http://wtwitter.codeplex.com/SourceControl/changeset/view/41418

1 件のコメント:

  1. すみません。明らかにおかしいバグがありました。
    TextComponentsConverterは
    最新のソースコードを参照してください。
    http://wtwitter.codeplex.com/SourceControl/changeset/view/41801

    =>の中で、その外の変数を参照すると、
    『実行時に評価される』ので、参照先のアイテムが変化しています。
    foreachの3巡目に位置するアイテムがClickされても、
    Clickされた時にはforeachが回りきっているので、
    itemは異なるオブジェクトを参照していると言うことです。

    返信削除