1 /*
2  * SafeHtmlUtil.java
3  *
4  * Copyright (C) 2021 by RStudio, PBC
5  *
6  * Unless you have received this program directly from RStudio pursuant
7  * to the terms of a commercial license agreement with RStudio, then
8  * this program is licensed to you under the terms of version 3 of the
9  * GNU Affero General Public License. This program is distributed WITHOUT
10  * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11  * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12  * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13  *
14  */
15 package org.rstudio.core.client;
16 
17 import java.util.Set;
18 import java.util.TreeSet;
19 
20 import com.google.gwt.resources.client.ImageResource;
21 import com.google.gwt.safehtml.shared.SafeHtml;
22 import com.google.gwt.safehtml.shared.SafeHtmlBuilder;
23 import com.google.gwt.safehtml.shared.SafeHtmlUtils;
24 
25 public class SafeHtmlUtil
26 {
appendDiv(SafeHtmlBuilder sb, String style, String textContent)27    public static void appendDiv(SafeHtmlBuilder sb,
28                                 String style,
29                                 String textContent)
30    {
31       sb.append(createOpenTag("div",
32                               "class", style));
33       sb.appendEscaped(textContent);
34       sb.appendHtmlConstant("</div>");
35    }
36 
appendDiv(SafeHtmlBuilder sb, String style, SafeHtml htmlContent)37    public static void appendDiv(SafeHtmlBuilder sb,
38                                 String style,
39                                 SafeHtml htmlContent)
40    {
41       sb.append(createOpenTag("div",
42                               "class", style));
43       sb.append(htmlContent);
44       sb.appendHtmlConstant("</div>");
45    }
46 
appendSpan(SafeHtmlBuilder sb, String style, String textContent)47    public static void appendSpan(SafeHtmlBuilder sb,
48                                  String style,
49                                  String textContent)
50    {
51       sb.append(SafeHtmlUtil.createOpenTag("span",
52                                            "class", style));
53       sb.appendEscaped(textContent);
54       sb.appendHtmlConstant("</span>");
55    }
56 
appendSpan(SafeHtmlBuilder sb, String style, SafeHtml htmlContent)57    public static void appendSpan(SafeHtmlBuilder sb,
58                                  String style,
59                                  SafeHtml htmlContent)
60    {
61       sb.append(SafeHtmlUtil.createOpenTag("span",
62                                            "class", style));
63       sb.append(htmlContent);
64       sb.appendHtmlConstant("</span>");
65    }
66 
appendImage(SafeHtmlBuilder sb, String style, ImageResource image)67    public static void appendImage(SafeHtmlBuilder sb,
68                                   String style,
69                                   ImageResource image)
70    {
71       sb.append(SafeHtmlUtil.createOpenTag("img",
72                                            "class", style,
73                                            "width", Integer.toString(image.getWidth()),
74                                            "height", Integer.toString(image.getHeight()),
75                                            "src", image.getSafeUri().asString()));
76       sb.appendHtmlConstant("</img>");
77    }
78 
createOpenTag(String tagName, String... attribs)79    public static SafeHtml createOpenTag(String tagName,
80                                         String... attribs)
81    {
82       StringBuilder builder = new StringBuilder();
83       builder.append("<").append(tagName);
84       for (int i = 0; i < attribs.length; i += 2)
85       {
86          builder.append(' ')
87                .append(SafeHtmlUtils.htmlEscape(attribs[i]))
88                .append("=\"")
89                .append(SafeHtmlUtils.htmlEscape(attribs[i+1]))
90                .append("\"");
91       }
92       builder.append(">");
93       return SafeHtmlUtils.fromTrustedString(builder.toString());
94    }
95 
createDiv(String... attribs)96    public static SafeHtml createDiv(String... attribs)
97    {
98       return createOpenTag("div", attribs);
99    }
100 
createEmpty()101    public static SafeHtml createEmpty()
102    {
103       return SafeHtmlUtils.fromSafeConstant("");
104    }
105 
concat(SafeHtml... pieces)106    public static SafeHtml concat(SafeHtml... pieces)
107    {
108       StringBuilder builder = new StringBuilder();
109       for (SafeHtml piece : pieces)
110       {
111          if (piece != null)
112             builder.append(piece.asString());
113       }
114       return SafeHtmlUtils.fromTrustedString(builder.toString());
115    }
116 
createStyle(String... strings)117    public static SafeHtml createStyle(String... strings)
118    {
119       StringBuilder builder = new StringBuilder();
120       for (int i = 0, n = strings.length; i < n; i += 2)
121       {
122          String key = strings[i];
123          String value = strings[i + 1];
124 
125          builder.append(SafeHtmlUtils.htmlEscape(key))
126                 .append(": ")
127                 .append(SafeHtmlUtils.htmlEscape(value))
128                 .append("; ");
129       }
130       return SafeHtmlUtils.fromTrustedString(builder.toString());
131    }
132 
133    /**
134     * Appends text to a SafeHtmlBuilder with search matches highlighted.
135     *
136     * @param sb The SafeHtmlBuilder to append the search match to
137     * @param haystack The text to append.
138     * @param needle The text to search for and highlight.
139     * @param matchClass The CSS class to assign to matches.
140     */
highlightSearchMatch(SafeHtmlBuilder sb, String haystack, String needle, String matchClass)141    public static void highlightSearchMatch(SafeHtmlBuilder sb, String haystack,
142                                            String needle, String matchClass)
143    {
144       // do nothing if we weren't given a string
145       if (StringUtil.isNullOrEmpty(haystack))
146          return;
147 
148       // if we have a needle to search for, and it exists, highlight it
149       boolean hasMatch = false;
150       if (!StringUtil.isNullOrEmpty(needle))
151       {
152          int idx = haystack.toLowerCase().indexOf(needle);
153          if (idx >= 0)
154          {
155             hasMatch = true;
156             sb.appendEscaped(haystack.substring(0, idx));
157             sb.appendHtmlConstant(
158                   "<span class=\"" + matchClass + "\">");
159             sb.appendEscaped(haystack.substring(idx,
160                   idx + needle.length()));
161             sb.appendHtmlConstant("</span>");
162             sb.appendEscaped(haystack.substring(idx + needle.length(),
163                   haystack.length()));
164          }
165       }
166 
167       // needle not found; append text directly
168       if (!hasMatch)
169          sb.appendEscaped(haystack);
170    }
171 
172    /**
173     * Appends text to a SafeHtmlBuilder with multiple search matches highlighted.
174     *
175     * @param sb The SafeHtmlBuilder to append the search match to
176     * @param haystack The text to append.
177     * @param needles The strings to search for and highlight.
178     * @param matchClass The CSS class to assign to matches.
179     */
highlightSearchMatch(SafeHtmlBuilder sb, String haystack, String[] needles, String matchClass)180    public static void highlightSearchMatch(SafeHtmlBuilder sb, String haystack,
181                                            String[] needles, String matchClass)
182    {
183       // Do nothing if we weren't given a string
184       if (StringUtil.isNullOrEmpty(haystack))
185          return;
186 
187       // Inner class representing a search match found in the haystack
188       class SearchMatch
189       {
190          public SearchMatch(int indexIn, int lengthIn)
191          {
192             index = indexIn;
193             length = lengthIn;
194          }
195          public Integer index;
196          public Integer length;
197       };
198 
199       // Store matches in a tree set ordered by the index at which the match was
200       // found.
201       Set<SearchMatch> matches = new TreeSet<>(
202             (SearchMatch o1, SearchMatch o2) -> {
203                   return o1.index.compareTo(o2.index);
204             });
205 
206       // Find all the matches and add them to the result set.
207       for (int i = 0; i < needles.length; i++)
208       {
209          int idx = haystack.toLowerCase().indexOf(needles[i]);
210          if (idx >= 0)
211          {
212             int endIdx = idx + needles[i].length();
213 
214             // Check the existing set of matches; if this overlaps with an
215             // existing match we don't want to create overlapping match results.
216             boolean overlaps = false;
217             for (SearchMatch match: matches)
218             {
219                if (match.index >= endIdx)
220                {
221                   // Performance optimization: neither this match nor any
222                   // following can overlap since it starts after this match ends
223                   // (and matches are sorted by start index.)
224                   break;
225                }
226 
227                // If this match overlaps an existing match, merge it into that
228                // match instead of creating a new match.
229                int overlap = Math.min(endIdx, match.index + match.length) -
230                              Math.max(idx, match.index);
231                if (overlap > 0)
232                {
233                   // The match starts at the earlier of the indices
234                   match.index = Math.min(match.index, idx);
235 
236                   // The match's new length is the distance to its new endpoint
237                   // (the greater of the two matches we're merging)
238                   match.length = Math.max(endIdx,  match.index + match.length) -
239                         match.index;
240 
241                   overlaps = true;
242                   break;
243                }
244             }
245 
246             // If this match does not overlap any existing matches, add it as a
247             // new match.
248             if (!overlaps)
249             {
250                matches.add(new SearchMatch(idx, needles[i].length()));
251             }
252          }
253       }
254 
255       // Build the HTML from the input string and the found matches.
256       if (matches.size() > 0)
257       {
258          int idx = 0;
259          for (SearchMatch match: matches)
260          {
261             // Emit all the text from the last index to the beginning of this
262             // match.
263             sb.appendEscaped(haystack.substring(idx, match.index));
264 
265             // Emit the match itself.
266             idx = match.index;
267             sb.appendHtmlConstant(
268                   "<span class=\"" + matchClass + "\">");
269             sb.appendEscaped(haystack.substring(idx,
270                   idx + match.length));
271             sb.appendHtmlConstant("</span>");
272 
273             // Move the index to the end of this match
274             idx += match.length;
275          }
276 
277          // Emit the text from end of the last match to the end of the string
278          sb.appendEscaped(haystack.substring(idx, haystack.length()));
279       }
280       else
281       {
282          // We found no matches at all. Just emit the string into the builder.
283          sb.appendEscaped(haystack);
284       }
285    }
286 }
287 
288