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}