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