OSDN Git Service

graphqlエンドポイント使用時にツイート検索の言語指定が効かない不具合を修正
[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 #nullable enable
23
24 using System;
25 using System.Collections.Generic;
26 using System.Globalization;
27 using System.Linq;
28 using System.Text;
29 using System.Text.RegularExpressions;
30 using OpenTween.Api.DataModel;
31 using OpenTween.Setting;
32
33 namespace OpenTween
34 {
35     /// <summary>
36     /// ツイートの Entity 情報をもとにリンク化などを施すクラス
37     /// </summary>
38     public static class TweetFormatter
39     {
40         public static string AutoLinkHtml(string text, IEnumerable<TwitterEntity>? entities, bool keepTco = false)
41         {
42             if (entities == null)
43                 entities = Enumerable.Empty<TwitterEntity>();
44
45             var entitiesQuery = entities
46                 .Where(x => x != null)
47                 .Where(x => x.Indices != null && x.Indices.Length == 2);
48
49             return string.Concat(AutoLinkHtmlInternal(text, entitiesQuery, keepTco));
50         }
51
52         private static IEnumerable<string> AutoLinkHtmlInternal(string text, IEnumerable<TwitterEntity> entities, bool keepTco)
53         {
54             var curIndex = 0;
55
56             foreach (var entity in FixEntityIndices(text, entities))
57             {
58                 var startIndex = entity.Indices[0];
59                 var endIndex = entity.Indices[1];
60
61                 if (curIndex > startIndex)
62                     continue; // 区間が重複する不正なエンティティを無視する
63
64                 if (startIndex > endIndex)
65                     continue; // 区間が不正なエンティティを無視する
66
67                 if (startIndex > text.Length || endIndex > text.Length)
68                     continue; // 区間が文字列長を越えている不正なエンティティを無視する
69
70                 if (curIndex != startIndex)
71                     yield return T(E(text.Substring(curIndex, startIndex - curIndex)));
72
73                 var targetText = text.Substring(startIndex, endIndex - startIndex);
74
75                 yield return entity switch
76                 {
77                     TwitterEntityUrl urlEntity => FormatUrlEntity(targetText, urlEntity, keepTco),
78                     TwitterEntityHashtag hashtagEntity => FormatHashtagEntity(targetText, hashtagEntity),
79                     TwitterEntityMention mentionEntity => FormatMentionEntity(targetText, mentionEntity),
80                     TwitterEntityEmoji emojiEntity => FormatEmojiEntity(targetText, emojiEntity),
81                     _ => T(E(targetText)),
82                 };
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                 {
106                     if (i + 1 < text.Length && char.IsSurrogatePair(text[i], text[i + 1]))
107                         indexOffset++;
108                 }
109
110                 startIndex += indexOffset;
111                 curIndex = startIndex;
112
113                 for (var i = curIndex; i < (endIndex + indexOffset); i++)
114                 {
115                     if (i + 1 < text.Length && char.IsSurrogatePair(text[i], text[i + 1]))
116                         indexOffset++;
117                 }
118
119                 endIndex += indexOffset;
120                 curIndex = endIndex;
121
122                 entity.Indices[0] = startIndex;
123                 entity.Indices[1] = endIndex;
124
125                 yield return entity;
126             }
127         }
128
129         private static string FormatUrlEntity(string targetText, TwitterEntityUrl entity, bool keepTco)
130         {
131             string expandedUrl;
132
133             // 過去に存在した壊れたエンティティの対策
134             // 参照: https://dev.twitter.com/discussions/12628
135             if (entity.DisplayUrl == null || entity.ExpandedUrl == null)
136             {
137                 expandedUrl = MyCommon.ConvertToReadableUrl(targetText);
138                 return $"""<a href="{E(entity.Url)}" title="{E(expandedUrl)}">{T(E(targetText))}</a>""";
139             }
140
141             var linkUrl = entity.Url;
142
143             expandedUrl = keepTco ? linkUrl : MyCommon.ConvertToReadableUrl(entity.ExpandedUrl);
144
145             var mediaEntity = entity as TwitterEntityMedia;
146
147             var titleText = mediaEntity?.AltText ?? expandedUrl;
148
149             // twitter.com へのリンクは t.co を経由せずに直接リンクする (但し pic.twitter.com はそのまま)
150             if (mediaEntity == null)
151             {
152                 if (entity.ExpandedUrl.StartsWith("https://twitter.com/", StringComparison.Ordinal) ||
153                     entity.ExpandedUrl.StartsWith("http://twitter.com/", StringComparison.Ordinal))
154                 {
155                     linkUrl = entity.ExpandedUrl;
156                 }
157             }
158
159             return $"""<a href="{E(linkUrl)}" title="{E(titleText)}">{T(E(entity.DisplayUrl))}</a>""";
160         }
161
162         private static string FormatHashtagEntity(string targetText, TwitterEntityHashtag entity)
163             => $"""<a class="hashtag" href="https://twitter.com/search?q=%23{EU(entity.Text)}">{T(E(targetText))}</a>""";
164
165         private static string FormatMentionEntity(string targetText, TwitterEntityMention entity)
166             => $"""<a class="mention" href="https://twitter.com/{EU(entity.ScreenName)}">{T(E(targetText))}</a>""";
167
168         private static string FormatEmojiEntity(string targetText, TwitterEntityEmoji entity)
169         {
170             if (!SettingManager.Instance.Local.UseTwemoji)
171                 return T(E(targetText));
172
173             if (MyCommon.IsNullOrEmpty(entity.Url))
174                 return "";
175
176             return $"""<img class="emoji" src="{E(entity.Url)}" alt="{E(entity.Text)}" />""";
177         }
178
179         // 長いのでエイリアスとして e(...), eu(...), t(...) でエスケープできるようにする
180         private static readonly Func<string, string> E = EscapeHtml;
181         private static readonly Func<string, string> EU = Uri.EscapeDataString;
182         private static readonly Func<string, string> T = FilterText;
183
184         private static string EscapeHtml(string text)
185         {
186             // Twitter API は "<" ">" "&" だけ中途半端にエスケープした状態のテキストを返すため、
187             // これらの文字だけ一旦エスケープを解除する
188             text = text.Replace("&lt;", "<").Replace("&gt;", ">").Replace("&amp;", "&");
189
190             var result = new StringBuilder(100);
191             foreach (var c in text)
192             {
193                 // 「<」「>」「&」「"」「'」についてエスケープ処理を施す
194                 // 参照: http://d.hatena.ne.jp/ockeghem/20070510/1178813849
195                 result.Append(c switch
196                 {
197                     '<' => "&lt;",
198                     '>' => "&gt;",
199                     '&' => "&amp;",
200                     '"' => "&quot;",
201                     '\'' => "&#39;",
202                     _ => c,
203                 });
204             }
205
206             return result.ToString();
207         }
208
209         /// <summary>
210         /// HTML の属性値ではない、通常のテキストに対するフィルタ処理
211         /// </summary>
212         private static string FilterText(string text)
213         {
214             text = text.Replace("\n", "<br>");
215             text = Regex.Replace(text, "  ", " &nbsp;");
216
217             return text;
218         }
219     }
220 }