OSDN Git Service

Tweet Entityを使用したリンク化処理を書き直し
authorKimura Youichi <kim.upsilon@bucyou.net>
Tue, 11 Feb 2014 05:44:11 +0000 (14:44 +0900)
committerKimura Youichi <kim.upsilon@bucyou.net>
Tue, 11 Feb 2014 07:18:02 +0000 (16:18 +0900)
参照: https://sourceforge.jp/ticket/browse.php?group_id=6526&tid=33079

OpenTween.Tests/OpenTween.Tests.csproj
OpenTween.Tests/TweetFormatterTest.cs [new file with mode: 0644]
OpenTween/DataModel.cs
OpenTween/OpenTween.csproj
OpenTween/Resources/ChangeLog.txt
OpenTween/ShowUserInfo.cs
OpenTween/TweetFormatter.cs [new file with mode: 0644]
OpenTween/Twitter.cs

index 4e8cbc7..a7e038c 100644 (file)
@@ -75,6 +75,7 @@
     <Compile Include="Thumbnail\Services\SimpleThumbnailServiceTest.cs" />
     <Compile Include="Thumbnail\Services\TinamiTest.cs" />
     <Compile Include="ToolStripAPIGaugeTest.cs" />
+    <Compile Include="TweetFormatterTest.cs" />
     <Compile Include="TweetThumbnailTest.cs" />
     <Compile Include="TwitterTest.cs" />
   </ItemGroup>
diff --git a/OpenTween.Tests/TweetFormatterTest.cs b/OpenTween.Tests/TweetFormatterTest.cs
new file mode 100644 (file)
index 0000000..4ba7999
--- /dev/null
@@ -0,0 +1,324 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2014 kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see <http://www.gnu.org/licenses/>, or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Xunit;
+using Xunit.Extensions;
+
+namespace OpenTween
+{
+    public class TweetFormatterTest
+    {
+        [Fact]
+        public void FormatUrlEntity_Test()
+        {
+            var text = "http://t.co/KYi7vMZzRt";
+            var entities = new[]
+            {
+                new TwitterDataModel.Urls
+                {
+                    Indices = new[] { 0, 22 },
+                    DisplayUrl = "twitter.com",
+                    ExpandedUrl = "http://twitter.com/",
+                    Url = "http://t.co/KYi7vMZzRt",
+                },
+            };
+
+            var expected = "<a href=\"http://t.co/KYi7vMZzRt\" title=\"http://twitter.com/\">twitter.com</a>";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void FormatHashtagEntity_Test()
+        {
+            var text = "#OpenTween";
+            var entities = new[]
+            {
+                new TwitterDataModel.Hashtags
+                {
+                    Indices = new[] { 0, 10 },
+                    Text = "OpenTween",
+                },
+            };
+
+            var expected = "<a href=\"https://twitter.com/search?q=%23OpenTween\">#OpenTween</a>";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void FormatMentionEntity_Test()
+        {
+            var text = "@TwitterAPI";
+            var entities = new[]
+            {
+                new TwitterDataModel.UserMentions
+                {
+                    Indices = new[] { 0, 11 },
+                    Id = 6253282L,
+                    Name = "Twitter API",
+                    ScreenName = "twitterapi",
+                },
+            };
+
+            var expected = "<a href=\"https://twitter.com/twitterapi\">@TwitterAPI</a>";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void FormatMediaEntity_Test()
+        {
+            var text = "http://t.co/h5dCr4ftN4";
+            var entities = new[]
+            {
+                new TwitterDataModel.Media
+                {
+                    Indices = new[] { 0, 22 },
+                    Sizes = new TwitterDataModel.Sizes
+                    {
+                        Large = new TwitterDataModel.SizeElement { Resize = "fit", h = 329, w = 1024 },
+                        Medium = new TwitterDataModel.SizeElement { Resize = "fit", h = 204, w = 600 },
+                        Small = new TwitterDataModel.SizeElement { Resize = "fit", h = 116, w = 340 },
+                        Thumb = new TwitterDataModel.SizeElement { Resize = "crop", h = 150, w = 150 },
+                    },
+                    Type = "photo",
+                    Id = 426404550379986940L,
+                    MediaUrl = "http://pbs.twimg.com/media/BerkrewCYAAV4Kf.png",
+                    MediaUrlHttps = "https://pbs.twimg.com/media/BerkrewCYAAV4Kf.png",
+                    Url = "http://t.co/h5dCr4ftN4",
+                    DisplayUrl = "pic.twitter.com/h5dCr4ftN4",
+                    ExpandedUrl = "http://twitter.com/kim_upsilon/status/426404550371598337/photo/1",
+                },
+            };
+
+            var expected = "<a href=\"http://t.co/h5dCr4ftN4\" title=\"http://twitter.com/kim_upsilon/status/426404550371598337/photo/1\">pic.twitter.com/h5dCr4ftN4</a>";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void AutoLinkHtml_EntityNullTest()
+        {
+            var text = "てすとてすとー";
+            TwitterDataModel.Entities entities = null;
+
+            var expected = "てすとてすとー";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void AutoLinkHtml_EntityNullTest2()
+        {
+            var text = "てすとてすとー";
+            TwitterDataModel.Entities entities = new TwitterDataModel.Entities
+            {
+                Urls = null,
+                Hashtags = null,
+                UserMentions = null,
+                Media = null,
+            };
+
+            var expected = "てすとてすとー";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void AutoLinkHtml_EntityNullTest3()
+        {
+            var text = "てすとてすとー";
+            IEnumerable<TwitterDataModel.Entity> entities = null;
+
+            var expected = "てすとてすとー";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void AutoLinkHtml_EntityNullTest4()
+        {
+            var text = "てすとてすとー";
+            IEnumerable<TwitterDataModel.Entity> entities = new TwitterDataModel.Entity[] { null };
+
+            var expected = "てすとてすとー";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void AutoLinkHtml_EscapeTest()
+        {
+            // Twitter APIの中途半端なエスケープの対象とならない「"」や「'」に対するエスケープ処理を施す
+            var text = "\"\'@twitterapi\'\"";
+            var entities = new[]
+            {
+                new TwitterDataModel.UserMentions
+                {
+                    Indices = new[] { 2, 13 },
+                    Id = 6253282L,
+                    Name = "Twitter API",
+                    ScreenName = "twitterapi",
+                },
+            };
+
+            var expected = "&quot;&#39;<a href=\"https://twitter.com/twitterapi\">@twitterapi</a>&#39;&quot;";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void AutoLinkHtml_EscapeTest2()
+        {
+            // 「<」や「>」についてはエスケープされた状態でAPIからテキストが返されるため、二重エスケープとならないように考慮する
+            var text = "&lt;b&gt; @twitterapi &lt;/b&gt;";
+            var entities = new[]
+            {
+                new TwitterDataModel.UserMentions
+                {
+                    Indices = new[] { 10, 21 },
+                    Id = 6253282L,
+                    Name = "Twitter API",
+                    ScreenName = "twitterapi",
+                },
+            };
+
+            var expected = "&lt;b&gt; <a href=\"https://twitter.com/twitterapi\">@twitterapi</a> &lt;/b&gt;";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void AutoLinkHtml_EscapeTest3()
+        {
+            // 万が一「<」や「>」がエスケープされていない状態のテキストを受け取っても適切にエスケープが施されるようにする
+            var text = "<b> @twitterapi </b>";
+            var entities = new[]
+            {
+                new TwitterDataModel.UserMentions
+                {
+                    Indices = new[] { 4, 15 },
+                    Id = 6253282L,
+                    Name = "Twitter API",
+                    ScreenName = "twitterapi",
+                },
+            };
+
+            var expected = "&lt;b&gt; <a href=\"https://twitter.com/twitterapi\">@twitterapi</a> &lt;/b&gt;";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void AutoLinkHtml_EscapeUrlTest()
+        {
+            // 日本語ハッシュタグのリンク先URLを適切にエスケープする
+            var text = "#ぜんぶ雪のせいだ";
+            var entities = new[]
+            {
+                new TwitterDataModel.Hashtags
+                {
+                    Indices = new[] { 0, 9 },
+                    Text = "ぜんぶ雪のせいだ",
+                },
+            };
+
+            var expected = "<a href=\"https://twitter.com/search?q=%23%E3%81%9C%E3%82%93%E3%81%B6%E9%9B%AA%E3%81%AE%E3%81%9B%E3%81%84%E3%81%A0\">#ぜんぶ雪のせいだ</a>";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void AutoLinkHtml_SurrogatePairTest()
+        {
+            // UTF-16 で 4 バイトで表される文字を含むツイート
+            // 参照: https://sourceforge.jp/ticket/browse.php?group_id=6526&tid=33079
+            var text = "🐬🐬 @irucame 🐬🐬";
+            var entities = new[]
+            {
+                new TwitterDataModel.UserMentions
+                {
+                    Indices = new[] { 3, 11 },
+                    Id = 89942943L,
+                    ScreenName = "irucame",
+                },
+            };
+
+            var expected = "🐬🐬 <a href=\"https://twitter.com/irucame\">@irucame</a> 🐬🐬";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void AutoLinkHtml_SurrogatePairTest2()
+        {
+            // 現時点では存在しないものの、ハッシュタグなどエンティティ内にサロゲートペアが含まれる場合も考慮する
+            var text = "🐬🐬 #🐬🐬 🐬🐬 #🐬🐬 🐬🐬";
+            var entities = new[]
+            {
+                new TwitterDataModel.Hashtags
+                {
+                    Indices = new[] { 3, 6 },
+                    Text = "🐬🐬",
+                },
+                new TwitterDataModel.Hashtags
+                {
+                    Indices = new[] { 10, 13 },
+                    Text = "🐬🐬",
+                },
+            };
+
+            var expected = "🐬🐬 <a href=\"https://twitter.com/search?q=%23%F0%9F%90%AC%F0%9F%90%AC\">#🐬🐬</a> " +
+                "🐬🐬 <a href=\"https://twitter.com/search?q=%23%F0%9F%90%AC%F0%9F%90%AC\">#🐬🐬</a> 🐬🐬";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void AutoLinkHtml_CompositeCharacterTest()
+        {
+            // 合成文字 é ( \u00e9 ) を含むツイート
+            // 参照: https://dev.twitter.com/issues/251
+            var text = "Caf\u00e9 #test";
+            var entities = new[]
+            {
+                new TwitterDataModel.Hashtags
+                {
+                    Indices = new[] { 5, 10 },
+                    Text = "test",
+                },
+            };
+
+            var expected = "Caf\u00e9 <a href=\"https://twitter.com/search?q=%23test\">#test</a>";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+
+        [Fact]
+        public void AutoLinkHtml_CombiningCharacterSequenceTest()
+        {
+            // 結合文字列 é ( e + \u0301 ) を含むツイート
+            // 参照: https://dev.twitter.com/issues/251
+            var text = "Cafe\u0301 #test";
+            var entities = new[]
+            {
+                new TwitterDataModel.Hashtags
+                {
+                    Indices = new[] { 6, 11 },
+                    Text = "test",
+                },
+            };
+
+            var expected = "Cafe\u0301 <a href=\"https://twitter.com/search?q=%23test\">#test</a>";
+            Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities));
+        }
+    }
+}
index d4f7394..b25468c 100644 (file)
@@ -41,6 +41,12 @@ namespace OpenTween
         }
 
         [DataContract]
+        public class Entity
+        {
+            [DataMember(Name = "indices")] public int[] Indices = new int[3];
+        }
+
+        [DataContract]
         public class SizeElement
         {
             [DataMember(Name = "w")] public int w;
@@ -58,39 +64,32 @@ namespace OpenTween
         }
 
         [DataContract]
-        public class Media
+        public class Media : Urls
         {
             [DataMember(Name = "id")] public long Id;
             [DataMember(Name = "media_url")] public string MediaUrl;
             [DataMember(Name = "media_url_https")] public string MediaUrlHttps;
-            [DataMember(Name = "url")] public string Url;
-            [DataMember(Name = "display_url")] public string DisplayUrl;
-            [DataMember(Name = "expanded_url")] public string ExpandedUrl;
             [DataMember(Name = "sizes")] public Sizes Sizes;
             [DataMember(Name = "type")] public string Type;
-            [DataMember(Name = "indices")] public int[] Indices = new int[3];
         }
 
         [DataContract]
-        public class Urls
+        public class Urls : Entity
         {
             [DataMember(Name = "url")] public string Url;
             [DataMember(Name = "display_url")] public string DisplayUrl;
             [DataMember(Name = "expanded_url")] public string ExpandedUrl;
-            [DataMember(Name = "indices")] public int[] Indices = new int[3];
         }
 
         [DataContract]
-        public class Hashtags
+        public class Hashtags : Entity
         {
-            [DataMember(Name = "indices")] public int[] Indices = new int[3];
             [DataMember(Name = "text")] public string Text;
         }
 
         [DataContract]
-        public class UserMentions
+        public class UserMentions : Entity
         {
-            [DataMember(Name = "indices")] public int[] Indices = new int[3];
             [DataMember(Name = "screen_name")] public string ScreenName;
             [DataMember(Name = "name")] public string Name;
             [DataMember(Name = "id")] public Int64 Id;
index e0d7dbf..9952ad4 100644 (file)
     <Compile Include="Thumbnail\Services\Youtube.cs" />
     <Compile Include="Thumbnail\ThumbnailGenerator.cs" />
     <Compile Include="Thumbnail\ThumbnailInfo.cs" />
+    <Compile Include="TweetFormatter.cs" />
     <Compile Include="TweetThumbnail.cs">
       <SubType>UserControl</SubType>
     </Compile>
index 2fedb89..a6ba66e 100644 (file)
@@ -8,6 +8,7 @@
  * FIX: サムネイル画像の読み込み中に画面の更新が遅くなる問題の修正
  * FIX: 壊れたプロフィール画像を取得したり不安定な回線で読み込みを行うと操作不能な状態になることがある問題の修正
  * FIX: Twitter API から取得したツイートのエンティティ情報が不正な場合の対策を追加 (thx @nyamph_pf!)
+ * FIX: サロゲートペアを含むツイートのリンク化処理が正しく行われない問題の修正
 
 ==== Ver 1.1.7(2014/01/16)
  * NEW: ダイレクトメッセージに添付された画像のサムネイル表示に対応
index a41723a..5c710cb 100644 (file)
@@ -180,7 +180,7 @@ namespace OpenTween
             if (_info.RecentPost != null)
             {
                 recentPostTxt = MyOwner.createDetailHtml(
-                     MyOwner.TwitterInstance.CreateHtmlAnchor(ref _info.RecentPost, atlist, userInfo.Status.Entities, null) +
+                     MyOwner.TwitterInstance.CreateHtmlAnchor(_info.RecentPost, atlist, userInfo.Status.Entities, null) +
                      " Posted at " + _info.PostCreatedAt.ToString() +
                      " via " + _info.PostSource);
             }
diff --git a/OpenTween/TweetFormatter.cs b/OpenTween/TweetFormatter.cs
new file mode 100644 (file)
index 0000000..a1b786d
--- /dev/null
@@ -0,0 +1,214 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2014 kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see <http://www.gnu.org/licenses/>, or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+
+namespace OpenTween
+{
+    /// <summary>
+    /// ツイートの Entity 情報をもとにリンク化などを施すクラス
+    /// </summary>
+    public class TweetFormatter
+    {
+        public static string AutoLinkHtml(string text, TwitterDataModel.Entities entities)
+        {
+            if (entities == null)
+                return AutoLinkHtml(text, Enumerable.Empty<TwitterDataModel.Entity>());
+
+            if (entities.Urls == null)
+                entities.Urls = new TwitterDataModel.Urls[0];
+
+            if (entities.Hashtags == null)
+                entities.Hashtags = new TwitterDataModel.Hashtags[0];
+
+            if (entities.UserMentions == null)
+                entities.UserMentions = new TwitterDataModel.UserMentions[0];
+
+            if (entities.Media == null)
+                entities.Media = new TwitterDataModel.Media[0];
+
+            var entitiesQuery = entities.Urls.Cast<TwitterDataModel.Entity>()
+                .Concat(entities.Hashtags)
+                .Concat(entities.UserMentions)
+                .Concat(entities.Media)
+                .Where(x => x != null)
+                .Where(x => x.Indices != null && x.Indices.Length == 2);
+
+            return string.Concat(AutoLinkHtmlInternal(text, entitiesQuery));
+        }
+
+        public static string AutoLinkHtml(string text, IEnumerable<TwitterDataModel.Entity> entities)
+        {
+            if (entities == null)
+                entities = Enumerable.Empty<TwitterDataModel.Entity>();
+
+            var entitiesQuery = entities
+                .Where(x => x != null)
+                .Where(x => x.Indices != null && x.Indices.Length == 2);
+
+            return string.Concat(AutoLinkHtmlInternal(text, entitiesQuery));
+        }
+
+        private static IEnumerable<string> AutoLinkHtmlInternal(string text, IEnumerable<TwitterDataModel.Entity> entities)
+        {
+            var curIndex = 0;
+
+            foreach (var entity in FixEntityIndices(text, entities))
+            {
+                var startIndex = entity.Indices[0];
+                var endIndex = entity.Indices[1];
+
+                if (curIndex > startIndex)
+                    continue; // 区間が重複する不正なエンティティを無視する
+
+                if (startIndex > endIndex)
+                    continue; // 区間が不正なエンティティを無視する
+
+                if (startIndex > text.Length || endIndex > text.Length)
+                    continue; // 区間が文字列長を越えている不正なエンティティを無視する
+
+                if (curIndex != startIndex)
+                    yield return e(text.Substring(curIndex, startIndex - curIndex));
+
+                var targetText = text.Substring(startIndex, endIndex - startIndex);
+
+                if (entity is TwitterDataModel.Urls)
+                    yield return FormatUrlEntity(targetText, (TwitterDataModel.Urls)entity);
+                else if (entity is TwitterDataModel.Hashtags)
+                    yield return FormatHashtagEntity(targetText, (TwitterDataModel.Hashtags)entity);
+                else if (entity is TwitterDataModel.UserMentions)
+                    yield return FormatMentionEntity(targetText, (TwitterDataModel.UserMentions)entity);
+                else
+                    yield return e(targetText);
+
+                curIndex = endIndex;
+            }
+
+            if (curIndex != text.Length)
+                yield return e(text.Substring(curIndex));
+        }
+
+        /// <summary>
+        /// エンティティの Indices をサロゲートペアを考慮して調整します
+        /// </summary>
+        private static IEnumerable<TwitterDataModel.Entity> FixEntityIndices(string text, IEnumerable<TwitterDataModel.Entity> entities)
+        {
+            var curIndex = 0;
+            var indexOffset = 0; // サロゲートペアによる indices のズレを表す
+
+            foreach (var entity in entities.OrderBy(x => x.Indices[0]))
+            {
+                var startIndex = entity.Indices[0];
+                var endIndex = entity.Indices[1];
+
+                for (var i = curIndex; i < (startIndex + indexOffset); i++)
+                    if (i + 1 < text.Length && char.IsSurrogatePair(text[i], text[i + 1]))
+                        indexOffset++;
+
+                startIndex += indexOffset;
+                curIndex = startIndex;
+
+                for (var i = curIndex; i < (endIndex + indexOffset); i++)
+                    if (i + 1 < text.Length && char.IsSurrogatePair(text[i], text[i + 1]))
+                        indexOffset++;
+
+                endIndex += indexOffset;
+                curIndex = endIndex;
+
+                entity.Indices[0] = startIndex;
+                entity.Indices[1] = endIndex;
+
+                yield return entity;
+            }
+        }
+
+        private static string FormatUrlEntity(string targetText, TwitterDataModel.Urls entity)
+        {
+            string expandedUrl;
+
+            // 過去に存在した壊れたエンティティの対策
+            // 参照: https://dev.twitter.com/discussions/12628
+            if (entity.DisplayUrl == null)
+            {
+                expandedUrl = MyCommon.ConvertToReadableUrl(targetText);
+                return "<a href=\"" + e(entity.Url) + "\" title=\"" + e(expandedUrl) + "\">" + e(targetText) + "</a>";
+            }
+
+            expandedUrl = MyCommon.ConvertToReadableUrl(entity.ExpandedUrl);
+            return "<a href=\"" + e(entity.Url) + "\" title=\"" + e(expandedUrl) + "\">" + e(entity.DisplayUrl) + "</a>";
+        }
+
+        private static string FormatHashtagEntity(string targetText, TwitterDataModel.Hashtags entity)
+        {
+            return "<a href=\"https://twitter.com/search?q=%23" + eu(entity.Text) + "\">" + e(targetText) + "</a>";
+        }
+
+        private static string FormatMentionEntity(string targetText, TwitterDataModel.UserMentions entity)
+        {
+            return "<a href=\"https://twitter.com/" + eu(entity.ScreenName) + "\">" + e(targetText) + "</a>";
+        }
+
+        // 長いのでエイリアスとして e(...), eu(...) でエスケープできるようにする
+        private static Func<string, string> e = EscapeHtml;
+        private static Func<string, string> eu = Uri.EscapeDataString;
+
+        private static string EscapeHtml(string text)
+        {
+            // Twitter API は "<" と ">" だけ中途半端にエスケープした状態のテキストを返すため、
+            // これらの文字だけ一旦エスケープを解除する
+            text = text.Replace("&lt;", "<").Replace("&gt;", ">");
+
+            var result = new StringBuilder(100);
+            foreach (var c in text)
+            {
+                // 「<」「>」「&」「"」「'」についてエスケープ処理を施す
+                // 参照: http://d.hatena.ne.jp/ockeghem/20070510/1178813849
+                switch (c)
+                {
+                    case '<':
+                        result.Append("&lt;");
+                        break;
+                    case '>':
+                        result.Append("&gt;");
+                        break;
+                    case '&':
+                        result.Append("&amp;");
+                        break;
+                    case '"':
+                        result.Append("&quot;");
+                        break;
+                    case '\'':
+                        result.Append("&#39;");
+                        break;
+                    default:
+                        result.Append(c);
+                        break;
+                }
+            }
+
+            return result.ToString();
+        }
+    }
+}
index b54b8b0..f8d2c5b 100644 (file)
@@ -1615,7 +1615,7 @@ namespace OpenTween
             }
             //HTMLに整形
             string textFromApi = post.TextFromApi;
-            post.Text = CreateHtmlAnchor(ref textFromApi, post.ReplyToList, entities, post.Media);
+            post.Text = CreateHtmlAnchor(textFromApi, post.ReplyToList, entities, post.Media);
             post.TextFromApi = textFromApi;
             post.TextFromApi = this.ReplaceTextFromApi(post.TextFromApi, entities);
             post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
@@ -2028,7 +2028,7 @@ namespace OpenTween
                     //本文
                     var textFromApi = message.Text;
                     //HTMLに整形
-                    post.Text = CreateHtmlAnchor(ref textFromApi, post.ReplyToList, message.Entities, post.Media);
+                    post.Text = CreateHtmlAnchor(textFromApi, post.ReplyToList, message.Entities, post.Media);
                     post.TextFromApi = this.ReplaceTextFromApi(textFromApi, message.Entities);
                     post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
                     post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
@@ -2271,7 +2271,7 @@ namespace OpenTween
                     }
                     //HTMLに整形
                     string textFromApi = post.TextFromApi;
-                    post.Text = CreateHtmlAnchor(ref textFromApi, post.ReplyToList, entities, post.Media);
+                    post.Text = CreateHtmlAnchor(textFromApi, post.ReplyToList, entities, post.Media);
                     post.TextFromApi = textFromApi;
                     post.TextFromApi = this.ReplaceTextFromApi(post.TextFromApi, entities);
                     post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
@@ -2922,144 +2922,52 @@ namespace OpenTween
             return retStr;
         }
 
-        private class EntityInfo
+        public string CreateHtmlAnchor(string text, List<string> AtList, TwitterDataModel.Entities entities, Dictionary<string, string> media)
         {
-            public int StartIndex { get; set; }
-            public int EndIndex { get; set; }
-            public string Text { get; set; }
-            public string Html { get; set; }
-            public string Display { get; set; }
-        }
-        public string CreateHtmlAnchor(ref string Text, List<string> AtList, TwitterDataModel.Entities entities, Dictionary<string, string> media)
-        {
-            var ret = Text;
-
             if (entities != null)
             {
-                var etInfo = new SortedList<int, EntityInfo>();
-                //URL
                 if (entities.Urls != null)
                 {
                     foreach (var ent in entities.Urls)
                     {
-                        var startIndex = ent.Indices[0];
-                        var endIndex = ent.Indices[1];
-
-                        if (etInfo.ContainsKey(startIndex))
-                            continue;
+                        ent.ExpandedUrl = ShortUrl.ResolveMedia(ent.ExpandedUrl, false);
 
-                        if (string.IsNullOrEmpty(ent.DisplayUrl))
-                        {
-                            etInfo.Add(startIndex,
-                                       new EntityInfo {StartIndex = startIndex,
-                                                       EndIndex = endIndex,
-                                                       Text = ent.Url,
-                                                       Html = "<a href=\"" + ent.Url + "\">" + ent.Url + "</a>"});
-                        }
-                        else
-                        {
-                            var expanded = ShortUrl.ResolveMedia(ent.ExpandedUrl, false);
-                            etInfo.Add(startIndex,
-                                       new EntityInfo {StartIndex = startIndex,
-                                                       EndIndex = endIndex,
-                                                       Text = ent.Url,
-                                                       Html = "<a href=\"" + ent.Url + "\" title=\"" + MyCommon.ConvertToReadableUrl(expanded) + "\">" + ent.DisplayUrl + "</a>",
-                                                       Display = ent.DisplayUrl});
-                            if (media != null && !media.ContainsKey(ent.Url)) media.Add(ent.Url, expanded);
-                        }
+                        if (media != null && !media.ContainsKey(ent.Url))
+                            media.Add(ent.Url, ent.ExpandedUrl);
                     }
                 }
                 if (entities.Hashtags != null)
                 {
-                    foreach (var ent in entities.Hashtags)
+                    lock (this.LockObj)
                     {
-                        var startIndex = ent.Indices[0];
-                        var endIndex = ent.Indices[1];
-
-                        if (etInfo.ContainsKey(startIndex))
-                            continue;
-
-                        var hash = Text.Substring(startIndex, endIndex - startIndex);
-                        etInfo.Add(startIndex,
-                                   new EntityInfo {StartIndex = startIndex,
-                                                   EndIndex = endIndex,
-                                                   Text = hash,
-                                                   Html = "<a href=\"https://twitter.com/search?q=%23" + ent.Text + "\">" + hash + "</a>"});
-                        lock (LockObj)
-                        {
-                            _hashList.Add("#" + ent.Text);
-                        }
+                        this._hashList.AddRange(entities.Hashtags.Select(x => "#" + x.Text));
                     }
                 }
                 if (entities.UserMentions != null)
                 {
                     foreach (var ent in entities.UserMentions)
                     {
-                        var startIndex = ent.Indices[0] + 1;
-                        var endIndex = ent.Indices[1];
-
-                        if (etInfo.ContainsKey(startIndex))
-                            continue;
-
-                        var screenName = Text.Substring(startIndex, endIndex - startIndex);
-                        etInfo.Add(startIndex,
-                                   new EntityInfo {StartIndex = startIndex,
-                                                   EndIndex = endIndex,
-                                                   Text = ent.ScreenName,
-                                                   Html = "<a href=\"/" + ent.ScreenName + "\">" + screenName + "</a>"});
-                        if (!AtList.Contains(ent.ScreenName.ToLower())) AtList.Add(ent.ScreenName.ToLower());
+                        var screenName = ent.ScreenName.ToLower();
+                        if (!AtList.Contains(screenName))
+                            AtList.Add(screenName);
                     }
                 }
                 if (entities.Media != null)
                 {
                     foreach (var ent in entities.Media)
                     {
-                        if (ent.Type == "photo")
-                        {
-                            var startIndex = ent.Indices[0];
-                            var endIndex = ent.Indices[1];
-
-                            // entities.Urls との重複を考慮
-                            if (etInfo.ContainsKey(startIndex))
-                                continue;
-
-                            etInfo.Add(startIndex,
-                                       new EntityInfo {StartIndex = startIndex,
-                                                       EndIndex = endIndex,
-                                                       Text = ent.Url,
-                                                       Html = "<a href=\"" + ent.Url + "\" title=\"" + ent.ExpandedUrl + "\">" + ent.DisplayUrl + "</a>",
-                                                       Display = ent.DisplayUrl});
-                            if (media != null && !media.ContainsKey(ent.Url)) media.Add(ent.Url, ent.MediaUrl);
-                        }
-                    }
-                }
-                if (etInfo.Count > 0)
-                {
-                    try
-                    {
-                        var idx = 0;
-                        ret = "";
-                        foreach (var et in etInfo)
-                        {
-                            ret += Text.Substring(idx, et.Key - idx) + et.Value.Html;
-                            idx = et.Value.EndIndex;
-                        }
-                        ret += Text.Substring(idx);
-                    }
-                    catch(ArgumentOutOfRangeException)
-                    {
-                        //Twitterのバグで不正なエンティティ(Index指定範囲が重なっている)が返ってくる場合の対応
-                        ret = Text;
-                        entities = null;
-                        if (media != null) media.Clear();
+                        if (media != null && !media.ContainsKey(ent.Url))
+                            media.Add(ent.Url, ent.MediaUrl);
                     }
                 }
             }
 
-            ret = Regex.Replace(ret, "(^|[^a-zA-Z0-9_/&##@@>=.~])(sm|nm)([0-9]{1,10})", "$1<a href=\"http://www.nicovideo.jp/watch/$2$3\">$2$3</a>");
-            ret = AdjustHtml(ShortUrl.Resolve(PreProcessUrl(ret), false)); //IDN置換、短縮Uri解決、@リンクを相対→絶対にしてtarget属性付与
+            text = TweetFormatter.AutoLinkHtml(text, entities);
 
-            return ret;
+            text = Regex.Replace(text, "(^|[^a-zA-Z0-9_/&##@@>=.~])(sm|nm)([0-9]{1,10})", "$1<a href=\"http://www.nicovideo.jp/watch/$2$3\">$2$3</a>");
+            text = AdjustHtml(ShortUrl.Resolve(PreProcessUrl(text), false)); //IDN置換、短縮Uri解決、@リンクを相対→絶対にしてtarget属性付与
+
+            return text;
         }
 
         //Source整形