001/* 002 * Copyright 2017 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.web.glyph; 017 018import java.io.IOException; 019import java.io.InputStream; 020import java.net.MalformedURLException; 021import java.util.ArrayList; 022import java.util.Collections; 023import java.util.HashMap; 024import java.util.HashSet; 025import java.util.LinkedHashSet; 026import java.util.List; 027import java.util.Map; 028import java.util.Set; 029import java.util.regex.Matcher; 030import java.util.regex.Pattern; 031 032import javax.xml.parsers.SAXParserFactory; 033 034import org.apache.avalon.framework.component.Component; 035import org.apache.avalon.framework.configuration.Configuration; 036import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder; 037import org.apache.avalon.framework.context.Context; 038import org.apache.avalon.framework.context.ContextException; 039import org.apache.avalon.framework.context.Contextualizable; 040import org.apache.avalon.framework.logger.AbstractLogEnabled; 041import org.apache.avalon.framework.service.ServiceException; 042import org.apache.avalon.framework.service.ServiceManager; 043import org.apache.avalon.framework.service.Serviceable; 044import org.apache.cocoon.components.ContextHelper; 045import org.apache.cocoon.environment.Request; 046import org.apache.commons.lang.StringUtils; 047import org.apache.excalibur.source.Source; 048import org.apache.excalibur.source.SourceResolver; 049import org.w3c.css.sac.InputSource; 050import org.w3c.dom.css.CSSFontFaceRule; 051import org.w3c.dom.css.CSSRule; 052import org.w3c.dom.css.CSSRuleList; 053import org.w3c.dom.css.CSSStyleDeclaration; 054import org.w3c.dom.css.CSSStyleRule; 055import org.w3c.dom.css.CSSStyleSheet; 056import org.xml.sax.XMLReader; 057 058import org.ametys.core.ui.Callable; 059import org.ametys.web.repository.site.Site; 060import org.ametys.web.repository.site.SiteManager; 061 062import com.steadystate.css.parser.CSSOMParser; 063import com.steadystate.css.parser.SACParserCSS3; 064 065/** 066 * Manager for skin glyph sources 067 */ 068public class SkinGlyphSourceManager extends AbstractLogEnabled implements Serviceable, Component, Contextualizable 069{ 070 /** The Avalon Role */ 071 public static final String ROLE = SkinGlyphSourceManager.class.getName(); 072 073 /** Pattern for CSS class */ 074 private static final Pattern __CSS_CLASS_NAME = Pattern.compile("\\.-?[_a-zA-Z]+[_a-zA-Z0-9-]*\\s*"); 075 076 private SourceResolver _resolver; 077 078 private SiteManager _siteManager; 079 080 private Context _context; 081 082 private Map<String, Long> _lastUpdates = new HashMap<>(); 083 084 private Map<String, List<String>> _cssFiles = new HashMap<>(); 085 private Map<String, Set<String>> _glyphes = new HashMap<>(); 086 087 @Override 088 public void service(ServiceManager manager) throws ServiceException 089 { 090 _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE); 091 _resolver = (SourceResolver) manager.lookup(SourceResolver.ROLE); 092 } 093 094 @Override 095 public void contextualize(Context context) throws ContextException 096 { 097 _context = context; 098 } 099 100 /** 101 * Determines if the skin has available glyphesIde 102 * @param siteName The site name 103 * @return true if the skin has available glyphes 104 * @throws Exception if failed to read skin files 105 */ 106 @Callable 107 public boolean hasGlyphes(String siteName) throws Exception 108 { 109 Site site = _siteManager.getSite(siteName); 110 String skinName = site.getSkinId(); 111 112 return !getGlyphes(skinName).isEmpty(); 113 } 114 115 /** 116 * Get the CSS class names for glyphes contained in the skin 117 * @param skinName The skin name 118 * @return The CSS class names for glyphes 119 * @throws Exception if failed to read skin files 120 */ 121 public Set<String> getGlyphes(String skinName) throws Exception 122 { 123 Set<String> glyphes = new LinkedHashSet<>(); 124 125 List<String> relativePathsWithFontFace = getRelativeCssFilesWithFontFace(skinName); 126 for (String relativePath : relativePathsWithFontFace) 127 { 128 String sourceUri = "skin://resources/" + relativePath; 129 glyphes.addAll(getGlyphClassNames(skinName, sourceUri)); 130 } 131 132 return glyphes; 133 } 134 135 /** 136 * Get the CSS files with 'font-face' rule in the skin 137 * @param siteName The site name 138 * @return The CSS files with font 139 * @throws Exception If failed to get the list of CSS files with 'font-face' rule 140 */ 141 @Callable 142 public Map<String, Object> getCSSFiles(String siteName) throws Exception 143 { 144 Request request = ContextHelper.getRequest(_context); 145 146 Site site = _siteManager.getSite(siteName); 147 String skinName = site.getSkinId(); 148 149 String resourcePath = request.getContextPath() + "/skins/" + skinName + "/resources/"; 150 151 Map<String, Object> result = new HashMap<>(); 152 153 if (StringUtils.isNotBlank(skinName)) 154 { 155 List<String> relativePathsWithFontFace = getRelativeCssFilesWithFontFace(skinName); 156 157 List<String> cssPaths = new ArrayList<>(); 158 for (String relativePath : relativePathsWithFontFace) 159 { 160 cssPaths.add(resourcePath + relativePath); 161 } 162 result.put("cssFiles", cssPaths); 163 } 164 return result; 165 } 166 167 /** 168 * Get the URI of CSS files with the 'font-face' rule 169 * @param skinName The skin name 170 * @return the URI of CSS files with the 'font-face' rule 171 * @throws Exception if fails to read conf/glyph.xml file 172 */ 173 public List<String> getRelativeCssFilesWithFontFace(String skinName) throws Exception 174 { 175 List<String> relativeCssFiles = _getRelativeCssFiles(skinName); 176 177 List<String> relativeCssFilesWithFontFace = new ArrayList<>(); 178 for (String relativeCssFile : relativeCssFiles) 179 { 180 String cssURI = "skin://resources/" + relativeCssFile; 181 Source source = _resolver.resolveURI(cssURI); 182 if (source.exists()) 183 { 184 Long lastUpdate = _lastUpdates.get(source.getURI()); 185 186 if (lastUpdate == null || lastUpdate < source.getLastModified()) 187 { 188 CSSStyleSheet cssStyleSheet = _getCssStyleSheet(source); 189 CSSRuleList cssRules = cssStyleSheet.getCssRules(); 190 191 boolean fontFaceFound = false; 192 for (int i = 0; i < cssRules.getLength(); i++) 193 { 194 CSSRule cssRule = cssRules.item(i); 195 if (cssRule instanceof CSSFontFaceRule) 196 { 197 relativeCssFilesWithFontFace.add(relativeCssFile); 198 fontFaceFound = true; 199 break; 200 } 201 } 202 203 if (!fontFaceFound) 204 { 205 getLogger().warn("Font-face rule was not found in CSS style sheet '" + cssURI + "'. It will be ignored"); 206 } 207 } 208 else 209 { 210 // if file is in last update cache, the file is necessary a font css 211 relativeCssFilesWithFontFace.add(relativeCssFile); 212 } 213 } 214 215 } 216 return relativeCssFilesWithFontFace; 217 } 218 219 /** 220 * Get the URI of CSS files listed in conf/glyph.xml file 221 * @param skinName The skin name 222 * @return The list of css files 223 * @throws Exception if fails to read conf/glyph.xml file 224 */ 225 private List<String> _getRelativeCssFiles(String skinName) throws Exception 226 { 227 Source src = null; 228 try 229 { 230 SAXParserFactory factory = SAXParserFactory.newInstance(); 231 232 XMLReader reader = factory.newSAXParser().getXMLReader(); 233 234 DefaultConfigurationBuilder confBuilder = new DefaultConfigurationBuilder(reader); 235 236 String confFileUri = "context://skins/" + skinName + "/conf/fonts.xml"; 237 238 src = _resolver.resolveURI(confFileUri); 239 240 if (src.exists()) 241 { 242 Long lastUpdate = _lastUpdates.get(src.getURI()); 243 244 if (lastUpdate == null || lastUpdate < src.getLastModified()) 245 { 246 List<String> files = new ArrayList<>(); 247 248 try (InputStream is = src.getInputStream()) 249 { 250 251 Configuration configuration = confBuilder.build(is, src.getURI()); 252 Configuration[] filesConfiguration = configuration.getChildren("file"); 253 254 for (Configuration fileConfiguration : filesConfiguration) 255 { 256 String file = fileConfiguration.getValue(); 257 files.add(file); 258 } 259 } 260 _cssFiles.put(skinName, files); 261 _lastUpdates.put(src.getURI(), src.getLastModified()); 262 } 263 264 return _cssFiles.get(skinName); 265 } 266 } 267 finally 268 { 269 _resolver.release(src); 270 } 271 272 return Collections.EMPTY_LIST; 273 } 274 275 private CSSStyleSheet _getCssStyleSheet(Source source) throws MalformedURLException, IOException 276 { 277 try 278 { 279 InputSource inputSource = new InputSource(); 280 inputSource.setByteStream(source.getInputStream()); 281 inputSource.setEncoding("UTF-8"); 282 CSSOMParser parser = new CSSOMParser(new SACParserCSS3()); 283 return parser.parseStyleSheet(inputSource, null, null); 284 } 285 finally 286 { 287 _resolver.release(source); 288 } 289 } 290 291 /** 292 * Get CSS class name to use for all icons. Can be empty if the icons contains the font-family to use. 293 * @param styleSheet The CSS stylesheet to parse 294 * @return The name of the common CSS class name for icons (ex: 'fa' for FontAwesome). Can be empty if the icons contains the font-family to use. 295 */ 296 public Set<String> getFontClassNames(CSSStyleSheet styleSheet) 297 { 298 CSSRuleList rules = styleSheet.getCssRules(); 299 300 Set<String> fontClassNames = new HashSet<>(); 301 Set<String> fontFamilies = _getFontFamily(styleSheet); 302 303 for (int i = 0; i < rules.getLength(); i++) 304 { 305 CSSRule rule = rules.item(i); 306 if (rule instanceof CSSStyleRule) 307 { 308 // Search if there is a common CSS class to all icons as FontAwesome does : 309 // .fa { font: normal normal normal 14px/1 FontAwesome } 310 // .fa-bath { content: "\f2cd"} 311 // => We need here to extract "fa" CSS classname 312 313 CSSStyleRule styleRule = (CSSStyleRule) rule; 314 CSSStyleDeclaration style = styleRule.getStyle(); 315 316 if (StringUtils.isEmpty(style.getPropertyValue("content"))) 317 { 318 if (_matchFontFamily(fontFamilies, style)) 319 { 320 String selectorText = styleRule.getSelectorText(); 321 Matcher matcher = __CSS_CLASS_NAME.matcher(selectorText); 322 while (matcher.find()) 323 { 324 fontClassNames.add(matcher.group().substring(1)); 325 } 326 } 327 } 328 } 329 } 330 331 return fontClassNames; 332 } 333 334 /** 335 * Get the CSS class names for glyphes 336 * @param skinName The skin name 337 * @param cssURI The URI of CSS file 338 * @return The CSS class names for glyphes 339 * @throws IOException If fails to parse CSS file 340 * @throws MalformedURLException if URI is malformed 341 */ 342 public Set<String> getGlyphClassNames (String skinName, String cssURI) throws MalformedURLException, IOException 343 { 344 Source source = _resolver.resolveURI(cssURI); 345 if (source.exists()) 346 { 347 Long lastUpdate = _lastUpdates.get(source.getURI()); 348 349 if (lastUpdate == null || lastUpdate < source.getLastModified()) 350 { 351 Set<String> classNames = new LinkedHashSet<>(); 352 353 CSSStyleSheet styleSheet = _getCssStyleSheet(source); 354 355 Set<String> commonClassNames = getFontClassNames(styleSheet); 356 357 CSSRuleList rules = styleSheet.getCssRules(); 358 for (int i = 0; i < rules.getLength(); i++) 359 { 360 CSSRule rule = rules.item(i); 361 362 if (rule instanceof CSSStyleRule) 363 { 364 CSSStyleDeclaration style = ((CSSStyleRule) rule).getStyle(); 365 366 // Filter CSS style with 'content' property 367 if (StringUtils.isNotEmpty(style.getPropertyValue("content"))) 368 { 369 String selectorText = ((CSSStyleRule) rule).getSelectorText(); 370 Matcher matcher = __CSS_CLASS_NAME.matcher(selectorText); 371 while (matcher.find()) 372 { 373 // Remove point at the begining of css class name 374 String className = matcher.group().substring(1); 375 376 if (commonClassNames.isEmpty()) 377 { 378 classNames.add(className); 379 } 380 else if (commonClassNames.contains(className)) 381 { 382 // This case means that the class name for glyph already contains the font-family in another rule and so was detected as a "common" class name 383 // but it is not a common class name (see in icomoon.css for example) 384 classNames.add(className); 385 } 386 else 387 { 388 for (String commonClassName : commonClassNames) 389 { 390 String computedClassName = commonClassName + " " + className; 391 classNames.add(computedClassName); 392 } 393 } 394 } 395 } 396 } 397 } 398 399 _lastUpdates.put(source.getURI(), source.getLastModified()); 400 _glyphes.put(source.getURI(), classNames); 401 } 402 403 return _glyphes.get(source.getURI()); 404 } 405 else 406 { 407 getLogger().warn("The CSS file " + cssURI + " does not exist"); 408 } 409 410 return Collections.EMPTY_SET; 411 } 412 413 private boolean _matchFontFamily(Set<String> fontFamilies, CSSStyleDeclaration style) 414 { 415 String fontValue = style.getPropertyValue("font"); 416 String fontFamilyValue = style.getPropertyValue("font-family"); 417 418 if (StringUtils.isNotBlank(fontValue) || StringUtils.isNotBlank(fontFamilyValue)) 419 { 420 for (String fontFamily : fontFamilies) 421 { 422 if ((StringUtils.isNotBlank(fontValue) && fontValue.contains(fontFamily)) || (StringUtils.isNotBlank(fontFamilyValue) && fontFamilyValue.contains(fontFamily))) 423 { 424 return true; 425 } 426 } 427 } 428 429 return false; 430 } 431 432 private Set<String> _getFontFamily(CSSStyleSheet styleSheet) 433 { 434 Set<String> fontsFamily = new HashSet<>(); 435 436 CSSRuleList cssRules = styleSheet.getCssRules(); 437 for (int i = 0; i < cssRules.getLength(); i++) 438 { 439 CSSRule cssRule = cssRules.item(i); 440 if (cssRule instanceof CSSFontFaceRule) 441 { 442 CSSStyleDeclaration style = ((CSSFontFaceRule) cssRule).getStyle(); 443 String fontFamily = style.getPropertyValue("font-family"); 444 fontsFamily.add(StringUtils.strip(StringUtils.strip(fontFamily, "\""), "'")); 445 } 446 } 447 return fontsFamily; 448 } 449}