OSDN Git Service

733141d27da217425f322bd0cb9da66a2c89ff6c
[opentween/open-tween.git] / OpenTween / TweetFormatter.cs
1 // OpenTween - Client of Twitter
2 // Copyright (c) 2014 kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
3 // All rights reserved.
4 //
5 // This file is part of OpenTween.
6 //
7 // This program is free software; you can redistribute it and/or modify it
8 // under the terms of the GNU General Public License as published by the Free
9 // Software Foundation; either version 3 of the License, or (at your option)
10 // any later version.
11 //
12 // This program is distributed in the hope that it will be useful, but
13 // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
14 // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
15 // for more details.
16 //
17 // You should have received a copy of the GNU General Public License along
18 // with this program. If not, see <http://www.gnu.org/licenses/>, or write to
19 // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
20 // Boston, MA 02110-1301, USA.
21
22 using System;
23 using System.Collections.Generic;
24 using System.Globalization;
25 using System.Linq;
26 using System.Text;
27 using System.Text.RegularExpressions;
28 using OpenTween.Api.DataModel;
29 using OpenTween.Setting;
30
31 namespace OpenTween
32 {
33     /// <summary>
34     /// ツイートの Entity 情報をもとにリンク化などを施すクラス
35     /// </summary>
36     public static class TweetFormatter
37     {
38         public static string AutoLinkHtml(string text, IEnumerable<TwitterEntity> entities, bool keepTco = false)
39         {
40             if (entities == null)
41                 entities = Enumerable.Empty<TwitterEntity>();
42
43             var entitiesQuery = entities
44                 .Where(x => x != null)
45                 .Where(x => x.Indices != null && x.Indices.Length == 2);
46
47             return string.Concat(AutoLinkHtmlInternal(text, entitiesQuery, keepTco));
48         }
49
50         private static IEnumerable<string> AutoLinkHtmlInternal(string text, IEnumerable<TwitterEntity> entities, bool keepTco)
51         {
52             var curIndex = 0;
53
54             foreach (var entity in FixEntityIndices(text, entities))
55             {
56                 var startIndex = entity.Indices[0];
57                 var endIndex = entity.Indices[1];
58
59                 if (curIndex > startIndex)
60                     continue; // 区間が重複する不正なエンティティを無視する
61
62                 if (startIndex > endIndex)
63                     continue; // 区間が不正なエンティティを無視する
64
65                 if (startIndex > text.Length || endIndex > text.Length)
66                     continue; // 区間が文字列長を越えている不正なエンティティを無視する
67
68                 if (curIndex != startIndex)
69                     yield return t(e(text.Substring(curIndex, startIndex - curIndex)));
70
71                 var targetText = text.Substring(startIndex, endIndex - startIndex);
72
73                 if (entity is TwitterEntityUrl urlEntity)
74                     yield return FormatUrlEntity(targetText, urlEntity, keepTco);
75                 else if (entity is TwitterEntityHashtag hashtagEntity)
76                     yield return FormatHashtagEntity(targetText, hashtagEntity);
77                 else if (entity is TwitterEntityMention mentionEntity)
78                     yield return FormatMentionEntity(targetText, mentionEntity);
79                 else if (entity is TwitterEntityEmoji emojiEntity)
80                     yield return FormatEmojiEntity(targetText, emojiEntity);
81                 else
82                     yield return t(e(targetText));
83
84                 curIndex = endIndex;
85             }
86
87             if (curIndex != text.Length)
88                 yield return t(e(text.Substring(curIndex)));
89         }
90
91         /// <summary>
92         /// エンティティの Indices をサロゲートペアを考慮して調整します
93         /// </summary>
94         private static IEnumerable<TwitterEntity> FixEntityIndices(string text, IEnumerable<TwitterEntity> entities)
95         {
96             var curIndex = 0;
97             var indexOffset = 0; // サロゲートペアによる indices のズレを表す
98
99             foreach (var entity in entities.OrderBy(x => x.Indices[0]))
100             {
101                 var startIndex = entity.Indices[0];
102                 var endIndex = entity.Indices[1];
103
104                 for (var i = curIndex; i < (startIndex + indexOffset); i++)
105                     if (i + 1 < text.Length && char.IsSurrogatePair(text[i], text[i + 1]))
106                         indexOffset++;
107
108                 startIndex += indexOffset;
109                 curIndex = startIndex;
110
111                 for (var i = curIndex; i < (endIndex + indexOffset); i++)
112                     if (i + 1 < text.Length && char.IsSurrogatePair(text[i], text[i + 1]))
113                         indexOffset++;
114
115                 endIndex += indexOffset;
116                 curIndex = endIndex;
117
118                 entity.Indices[0] = startIndex;
119                 entity.Indices[1] = endIndex;
120
121                 yield return entity;
122             }
123         }
124
125         private static string FormatUrlEntity(string targetText, TwitterEntityUrl entity, bool keepTco)
126         {
127             string expandedUrl;
128
129             // 過去に存在した壊れたエンティティの対策
130             // 参照: https://dev.twitter.com/discussions/12628
131             if (entity.DisplayUrl == null)
132             {
133                 expandedUrl = MyCommon.ConvertToReadableUrl(targetText);
134                 return "<a href=\"" + e(entity.Url) + "\" title=\"" + e(expandedUrl) + "\">" + t(e(targetText)) + "</a>";
135             }
136
137             var linkUrl = entity.Url;
138
139             expandedUrl = keepTco ? linkUrl : MyCommon.ConvertToReadableUrl(entity.ExpandedUrl);
140
141             var mediaEntity = entity as TwitterEntityMedia;
142
143             var titleText = mediaEntity?.AltText ?? expandedUrl;
144
145             // twitter.com へのリンクは t.co を経由せずに直接リンクする (但し pic.twitter.com はそのまま)
146             if (mediaEntity == null)
147             {
148                 if (entity.ExpandedUrl.StartsWith("https://twitter.com/", StringComparison.Ordinal) ||
149                     entity.ExpandedUrl.StartsWith("http://twitter.com/", StringComparison.Ordinal))
150                 {
151                     linkUrl = entity.ExpandedUrl;
152                 }
153             }
154
155             return "<a href=\"" + e(linkUrl) + "\" title=\"" + e(titleText) + "\">" + t(e(entity.DisplayUrl)) + "</a>";
156         }
157
158         private static string FormatHashtagEntity(string targetText, TwitterEntityHashtag entity)
159             => "<a class=\"hashtag\" href=\"https://twitter.com/search?q=%23" + eu(entity.Text) + "\">" + t(e(targetText)) + "</a>";
160
161         private static string FormatMentionEntity(string targetText, TwitterEntityMention entity)
162             => "<a class=\"mention\" href=\"https://twitter.com/" + eu(entity.ScreenName) + "\">" + t(e(targetText)) + "</a>";
163
164         private static string FormatEmojiEntity(string targetText, TwitterEntityEmoji entity)
165         {
166             if (!SettingManager.Local.UseTwemoji)
167                 return t(e(targetText));
168
169             return "<img class=\"emoji\" src=\"" + e(entity.Url) + "\" alt=\"" + e(entity.Text) + "\" />";
170         }
171
172         // 長いのでエイリアスとして e(...), eu(...), t(...) でエスケープできるようにする
173         private static Func<string, string> e = EscapeHtml;
174         private static Func<string, string> eu = Uri.EscapeDataString;
175         private static Func<string, string> t = FilterText;
176
177         private static string EscapeHtml(string text)
178         {
179             // Twitter API は "<" ">" "&" だけ中途半端にエスケープした状態のテキストを返すため、
180             // これらの文字だけ一旦エスケープを解除する
181             text = text.Replace("&lt;", "<").Replace("&gt;", ">").Replace("&amp;", "&");
182
183             var result = new StringBuilder(100);
184             foreach (var c in text)
185             {
186                 // 「<」「>」「&」「"」「'」についてエスケープ処理を施す
187                 // 参照: http://d.hatena.ne.jp/ockeghem/20070510/1178813849
188                 switch (c)
189                 {
190                     case '<':
191                         result.Append("&lt;");
192                         break;
193                     case '>':
194                         result.Append("&gt;");
195                         break;
196                     case '&':
197                         result.Append("&amp;");
198                         break;
199                     case '"':
200                         result.Append("&quot;");
201                         break;
202                     case '\'':
203                         result.Append("&#39;");
204                         break;
205                     default:
206                         result.Append(c);
207                         break;
208                 }
209             }
210
211             return result.ToString();
212         }
213
214         /// <summary>
215         /// HTML の属性値ではない、通常のテキストに対するフィルタ処理
216         /// </summary>
217         private static string FilterText(string text)
218         {
219             text = text.Replace("\n", "<br>");
220             text = Regex.Replace(text, "  ", " &nbsp;");
221
222             return text;
223         }
224     }
225 }