001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.mappaint.mapcss;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Color;
007import java.io.ByteArrayInputStream;
008import java.io.File;
009import java.io.IOException;
010import java.io.InputStream;
011import java.nio.charset.StandardCharsets;
012import java.text.MessageFormat;
013import java.util.ArrayList;
014import java.util.Collection;
015import java.util.Collections;
016import java.util.HashMap;
017import java.util.HashSet;
018import java.util.List;
019import java.util.Map;
020import java.util.Map.Entry;
021import java.util.Set;
022import java.util.concurrent.locks.ReadWriteLock;
023import java.util.concurrent.locks.ReentrantReadWriteLock;
024import java.util.zip.ZipEntry;
025import java.util.zip.ZipFile;
026
027import org.openstreetmap.josm.Main;
028import org.openstreetmap.josm.data.Version;
029import org.openstreetmap.josm.data.osm.Node;
030import org.openstreetmap.josm.data.osm.OsmPrimitive;
031import org.openstreetmap.josm.data.osm.Relation;
032import org.openstreetmap.josm.data.osm.Way;
033import org.openstreetmap.josm.gui.mappaint.Cascade;
034import org.openstreetmap.josm.gui.mappaint.Environment;
035import org.openstreetmap.josm.gui.mappaint.MultiCascade;
036import org.openstreetmap.josm.gui.mappaint.Range;
037import org.openstreetmap.josm.gui.mappaint.StyleSetting;
038import org.openstreetmap.josm.gui.mappaint.StyleSetting.BooleanStyleSetting;
039import org.openstreetmap.josm.gui.mappaint.StyleSource;
040import org.openstreetmap.josm.gui.mappaint.mapcss.Condition.SimpleKeyValueCondition;
041import org.openstreetmap.josm.gui.mappaint.mapcss.Selector.ChildOrParentSelector;
042import org.openstreetmap.josm.gui.mappaint.mapcss.Selector.GeneralSelector;
043import org.openstreetmap.josm.gui.mappaint.mapcss.Selector.OptimizedGeneralSelector;
044import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.MapCSSParser;
045import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.ParseException;
046import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.TokenMgrError;
047import org.openstreetmap.josm.gui.preferences.SourceEntry;
048import org.openstreetmap.josm.io.CachedFile;
049import org.openstreetmap.josm.tools.CheckParameterUtil;
050import org.openstreetmap.josm.tools.LanguageInfo;
051import org.openstreetmap.josm.tools.Utils;
052
053public class MapCSSStyleSource extends StyleSource {
054
055    /**
056     * The accepted MIME types sent in the HTTP Accept header.
057     * @since 6867
058     */
059    public static final String MAPCSS_STYLE_MIME_TYPES = "text/x-mapcss, text/mapcss, text/css; q=0.9, text/plain; q=0.8, application/zip, application/octet-stream; q=0.5";
060
061    // all rules
062    public final List<MapCSSRule> rules = new ArrayList<>();
063    // rule indices, filtered by primitive type
064    public final MapCSSRuleIndex nodeRules = new MapCSSRuleIndex();         // nodes
065    public final MapCSSRuleIndex wayRules = new MapCSSRuleIndex();          // ways without tag area=no
066    public final MapCSSRuleIndex wayNoAreaRules = new MapCSSRuleIndex();    // ways with tag area=no
067    public final MapCSSRuleIndex relationRules = new MapCSSRuleIndex();     // relations that are not multipolygon relations
068    public final MapCSSRuleIndex multipolygonRules = new MapCSSRuleIndex(); // multipolygon relations
069    public final MapCSSRuleIndex canvasRules = new MapCSSRuleIndex();       // rules to apply canvas properties
070
071    private Color backgroundColorOverride;
072    private String css = null;
073    private ZipFile zipFile;
074    
075    /**
076     * This lock prevents concurrent execution of {@link MapCSSRuleIndex#clear() } /
077     * {@link MapCSSRuleIndex#initIndex()} and {@link MapCSSRuleIndex#getRuleCandidates }.
078     * 
079     * For efficiency reasons, these methods are synchronized higher up the
080     * stack trace.
081     */
082    public final static ReadWriteLock STYLE_SOURCE_LOCK = new ReentrantReadWriteLock();
083
084    /**
085     * A collection of {@link MapCSSRule}s, that are indexed by tag key and value.
086     * 
087     * Speeds up the process of finding all rules that match a certain primitive.
088     * 
089     * Rules with a {@link SimpleKeyValueCondition} [key=value] are indexed by
090     * key and value in a HashMap. Now you only need to loop the tags of a
091     * primitive to retrieve the possibly matching rules.
092     * 
093     * Rules with no SimpleKeyValueCondition in the selector have to be
094     * checked separately.
095     * 
096     * The order of rules gets mixed up by this and needs to be sorted later.
097     */
098    public static class MapCSSRuleIndex {
099        /* all rules for this index */
100        public final List<MapCSSRule> rules = new ArrayList<>();
101        /* tag based index */
102        public final Map<String,Map<String,Set<MapCSSRule>>> index = new HashMap<>();
103        /* rules without SimpleKeyValueCondition */
104        public final Set<MapCSSRule> remaining = new HashSet<>();
105        
106        public void add(MapCSSRule rule) {
107            rules.add(rule);
108        }
109
110        /**
111         * Initialize the index.
112         * 
113         * You must own the write lock of STYLE_SOURCE_LOCK when calling this method.
114         */
115        public void initIndex() {
116            for (MapCSSRule r: rules) {
117                // find the rightmost selector, this must be a GeneralSelector
118                Selector selRightmost = r.selector;
119                while (selRightmost instanceof ChildOrParentSelector) {
120                    selRightmost = ((ChildOrParentSelector) selRightmost).right;
121                }
122                OptimizedGeneralSelector s = (OptimizedGeneralSelector) selRightmost;
123                if (s.conds == null) {
124                    remaining.add(r);
125                    continue;
126                }
127                List<SimpleKeyValueCondition> sk = new ArrayList<>(Utils.filteredCollection(s.conds, SimpleKeyValueCondition.class));
128                if (sk.isEmpty()) {
129                    remaining.add(r);
130                    continue;
131                }
132                SimpleKeyValueCondition c = sk.get(sk.size() - 1);
133                Map<String,Set<MapCSSRule>> rulesWithMatchingKey = index.get(c.k);
134                if (rulesWithMatchingKey == null) {
135                    rulesWithMatchingKey = new HashMap<>();
136                    index.put(c.k, rulesWithMatchingKey);
137                }
138                Set<MapCSSRule> rulesWithMatchingKeyValue = rulesWithMatchingKey.get(c.v);
139                if (rulesWithMatchingKeyValue == null) {
140                    rulesWithMatchingKeyValue = new HashSet<>();
141                    rulesWithMatchingKey.put(c.v, rulesWithMatchingKeyValue);
142                }
143                rulesWithMatchingKeyValue.add(r);
144            }
145        }
146        
147        /**
148         * Get a subset of all rules that might match the primitive.
149         * @param osm the primitive to match
150         * @return a Collection of rules that filters out most of the rules
151         * that cannot match, based on the tags of the primitive
152         * 
153         * You must have a read lock of STYLE_SOURCE_LOCK when calling this method.
154         */
155        public Collection<MapCSSRule> getRuleCandidates(OsmPrimitive osm) {
156            List<MapCSSRule> ruleCandidates = new ArrayList<>(remaining);
157            for (Map.Entry<String,String> e : osm.getKeys().entrySet()) {
158                Map<String,Set<MapCSSRule>> v = index.get(e.getKey());
159                if (v != null) {
160                    Set<MapCSSRule> rs = v.get(e.getValue());
161                    if (rs != null)  {
162                        ruleCandidates.addAll(rs);
163                    }
164                }
165            }
166            Collections.sort(ruleCandidates);
167            return ruleCandidates;
168        } 
169
170        /**
171         * Clear the index.
172         * 
173         * You must own the write lock STYLE_SOURCE_LOCK when calling this method.
174         */
175        public void clear() {
176            rules.clear();
177            index.clear();
178            remaining.clear();
179        }
180    }
181
182    public MapCSSStyleSource(String url, String name, String shortdescription) {
183        super(url, name, shortdescription);
184    }
185
186    public MapCSSStyleSource(SourceEntry entry) {
187        super(entry);
188    }
189
190    /**
191     * <p>Creates a new style source from the MapCSS styles supplied in
192     * {@code css}</p>
193     *
194     * @param css the MapCSS style declaration. Must not be null.
195     * @throws IllegalArgumentException thrown if {@code css} is null
196     */
197    public MapCSSStyleSource(String css) throws IllegalArgumentException{
198        super(null, null, null);
199        CheckParameterUtil.ensureParameterNotNull(css);
200        this.css = css;
201    }
202
203    @Override
204    public void loadStyleSource() {
205        STYLE_SOURCE_LOCK.writeLock().lock();
206        try {
207            init();
208            rules.clear();
209            nodeRules.clear();
210            wayRules.clear();
211            wayNoAreaRules.clear();
212            relationRules.clear();
213            multipolygonRules.clear();
214            canvasRules.clear();
215            try (InputStream in = getSourceInputStream()) {
216                try {
217                    // evaluate @media { ... } blocks
218                    MapCSSParser preprocessor = new MapCSSParser(in, "UTF-8", MapCSSParser.LexicalState.PREPROCESSOR);
219                    String mapcss = preprocessor.pp_root(this);
220
221                    // do the actual mapcss parsing
222                    InputStream in2 = new ByteArrayInputStream(mapcss.getBytes(StandardCharsets.UTF_8));
223                    MapCSSParser parser = new MapCSSParser(in2, "UTF-8", MapCSSParser.LexicalState.DEFAULT);
224                    parser.sheet(this);
225
226                    loadMeta();
227                    loadCanvas();
228                    loadSettings();
229                } finally {
230                    closeSourceInputStream(in);
231                }
232            } catch (IOException e) {
233                Main.warn(tr("Failed to load Mappaint styles from ''{0}''. Exception was: {1}", url, e.toString()));
234                Main.error(e);
235                logError(e);
236            } catch (TokenMgrError e) {
237                Main.warn(tr("Failed to parse Mappaint styles from ''{0}''. Error was: {1}", url, e.getMessage()));
238                Main.error(e);
239                logError(e);
240            } catch (ParseException e) {
241                Main.warn(tr("Failed to parse Mappaint styles from ''{0}''. Error was: {1}", url, e.getMessage()));
242                Main.error(e);
243                logError(new ParseException(e.getMessage())); // allow e to be garbage collected, it links to the entire token stream
244            }
245            // optimization: filter rules for different primitive types
246            for (MapCSSRule r: rules) {
247                // find the rightmost selector, this must be a GeneralSelector
248                Selector selRightmost = r.selector;
249                while (selRightmost instanceof ChildOrParentSelector) {
250                    selRightmost = ((ChildOrParentSelector) selRightmost).right;
251                }
252                MapCSSRule optRule = new MapCSSRule(r.selector.optimizedBaseCheck(), r.declaration);
253                final String base = ((GeneralSelector) selRightmost).getBase();
254                switch (base) {
255                    case "node":
256                        nodeRules.add(optRule);
257                        break;
258                    case "way":
259                        wayNoAreaRules.add(optRule);
260                        wayRules.add(optRule);
261                        break;
262                    case "area":
263                        wayRules.add(optRule);
264                        multipolygonRules.add(optRule);
265                        break;
266                    case "relation":
267                        relationRules.add(optRule);
268                        multipolygonRules.add(optRule);
269                        break;
270                    case "*":
271                        nodeRules.add(optRule);
272                        wayRules.add(optRule);
273                        wayNoAreaRules.add(optRule);
274                        relationRules.add(optRule);
275                        multipolygonRules.add(optRule);
276                        break;
277                    case "canvas":
278                        canvasRules.add(r);
279                        break;
280                    case "meta":
281                    case "setting":
282                        break;
283                    default:
284                        final RuntimeException e = new RuntimeException(MessageFormat.format("Unknown MapCSS base selector {0}", base));
285                        Main.warn(tr("Failed to parse Mappaint styles from ''{0}''. Error was: {1}", url, e.getMessage()));
286                        Main.error(e);
287                        logError(e);
288                }
289            }
290            nodeRules.initIndex();
291            wayRules.initIndex();
292            wayNoAreaRules.initIndex();
293            relationRules.initIndex();
294            multipolygonRules.initIndex();
295            canvasRules.initIndex();
296        } finally {
297            STYLE_SOURCE_LOCK.writeLock().unlock();
298        }
299    }
300    
301    @Override
302    public InputStream getSourceInputStream() throws IOException {
303        if (css != null) {
304            return new ByteArrayInputStream(css.getBytes(StandardCharsets.UTF_8));
305        }
306        CachedFile cf = getCachedFile();
307        if (isZip) {
308            File file = cf.getFile();
309            zipFile = new ZipFile(file, StandardCharsets.UTF_8);
310            zipIcons = file;
311            ZipEntry zipEntry = zipFile.getEntry(zipEntryPath);
312            return zipFile.getInputStream(zipEntry);
313        } else {
314            zipFile = null;
315            zipIcons = null;
316            return cf.getInputStream();
317        }
318    }
319
320    @Override
321    public CachedFile getCachedFile() throws IOException {
322        return new CachedFile(url).setHttpAccept(MAPCSS_STYLE_MIME_TYPES);
323    }
324
325    @Override
326    public void closeSourceInputStream(InputStream is) {
327        super.closeSourceInputStream(is);
328        if (isZip) {
329            Utils.close(zipFile);
330        }
331    }
332
333    /**
334     * load meta info from a selector "meta"
335     */
336    private void loadMeta() {
337        Cascade c = constructSpecial("meta");
338        String pTitle = c.get("title", null, String.class);
339        if (title == null) {
340            title = pTitle;
341        }
342        String pIcon = c.get("icon", null, String.class);
343        if (icon == null) {
344            icon = pIcon;
345        }
346    }
347
348    private void loadCanvas() {
349        Cascade c = constructSpecial("canvas");
350        backgroundColorOverride = c.get("fill-color", null, Color.class);
351        if (backgroundColorOverride == null) {
352            backgroundColorOverride = c.get("background-color", null, Color.class);
353            if (backgroundColorOverride != null) {
354                Main.warn(tr("Detected deprecated ''{0}'' in ''{1}'' which will be removed shortly. Use ''{2}'' instead.", "canvas{background-color}", url, "fill-color"));
355            }
356        }
357    }
358    
359    private void loadSettings() {
360        settings.clear();
361        settingValues.clear();
362        MultiCascade mc = new MultiCascade();
363        Node n = new Node();
364        String code = LanguageInfo.getJOSMLocaleCode();
365        n.put("lang", code);
366        // create a fake environment to read the meta data block
367        Environment env = new Environment(n, mc, "default", this);
368
369        for (MapCSSRule r : rules) {
370            if ((r.selector instanceof GeneralSelector)) {
371                GeneralSelector gs = (GeneralSelector) r.selector;
372                if (gs.getBase().equals("setting")) {
373                    if (!gs.matchesConditions(env)) {
374                        continue;
375                    }
376                    env.layer = gs.getSubpart();
377                    r.execute(env);
378                }
379            }
380        }
381        for (Entry<String, Cascade> e : mc.getLayers()) {
382            if ("default".equals(e.getKey())) {
383                Main.warn("setting requires layer identifier e.g. 'setting::my_setting {...}'");
384                continue;
385            }
386            Cascade c = e.getValue();
387            String type = c.get("type", null, String.class);
388            StyleSetting set = null;
389            if ("boolean".equals(type)) {
390                set = BooleanStyleSetting.create(c, this, e.getKey());
391            } else {
392                Main.warn("Unkown setting type: "+type);
393            }
394            if (set != null) {
395                settings.add(set);
396                settingValues.put(e.getKey(), set.getValue());
397            }
398        }
399    }
400
401    private Cascade constructSpecial(String type) {
402
403        MultiCascade mc = new MultiCascade();
404        Node n = new Node();
405        String code = LanguageInfo.getJOSMLocaleCode();
406        n.put("lang", code);
407        // create a fake environment to read the meta data block
408        Environment env = new Environment(n, mc, "default", this);
409
410        for (MapCSSRule r : rules) {
411            if ((r.selector instanceof GeneralSelector)) {
412                GeneralSelector gs = (GeneralSelector) r.selector;
413                if (gs.getBase().equals(type)) {
414                    if (!gs.matchesConditions(env)) {
415                        continue;
416                    }
417                    r.execute(env);
418                }
419            }
420        }
421        return mc.getCascade("default");
422    }
423
424    @Override
425    public Color getBackgroundColorOverride() {
426        return backgroundColorOverride;
427    }
428
429    @Override
430    public void apply(MultiCascade mc, OsmPrimitive osm, double scale, OsmPrimitive multipolyOuterWay, boolean pretendWayIsClosed) {
431        Environment env = new Environment(osm, mc, null, this);
432        MapCSSRuleIndex matchingRuleIndex;
433        if (osm instanceof Node) {
434            matchingRuleIndex = nodeRules;
435        } else if (osm instanceof Way) {
436            if (osm.isKeyFalse("area")) {
437                matchingRuleIndex = wayNoAreaRules;
438            } else {
439                matchingRuleIndex = wayRules;
440            }
441        } else {
442            if (((Relation) osm).isMultipolygon()) {
443                matchingRuleIndex = multipolygonRules;
444            } else if (osm.hasKey("#canvas")) {
445                matchingRuleIndex = canvasRules;
446            } else {
447                matchingRuleIndex = relationRules;
448            }
449        }
450        
451        // the declaration indices are sorted, so it suffices to save the
452        // last used index
453        int lastDeclUsed = -1;
454
455        for (MapCSSRule r : matchingRuleIndex.getRuleCandidates(osm)) {
456            env.clearSelectorMatchingInformation();
457            env.layer = r.selector.getSubpart();
458            if (r.selector.matches(env)) { // as side effect env.parent will be set (if s is a child selector)
459                Selector s = r.selector;
460                if (s.getRange().contains(scale)) {
461                    mc.range = Range.cut(mc.range, s.getRange());
462                } else {
463                    mc.range = mc.range.reduceAround(scale, s.getRange());
464                    continue;
465                }
466
467                if (r.declaration.idx == lastDeclUsed) continue; // don't apply one declaration more than once
468                lastDeclUsed = r.declaration.idx;
469                String sub = s.getSubpart();
470                if (sub == null) {
471                    sub = "default";
472                }
473                else if ("*".equals(sub)) {
474                    for (Entry<String, Cascade> entry : mc.getLayers()) {
475                        env.layer = entry.getKey();
476                        if ("*".equals(env.layer)) {
477                            continue;
478                        }
479                        r.execute(env);
480                    }
481                }
482                env.layer = sub;
483                r.execute(env);
484            }
485        }
486    }
487
488    public boolean evalMediaExpression(String feature, Object val) {
489        if ("user-agent".equals(feature)) {
490            String s = Cascade.convertTo(val, String.class);
491            if ("josm".equals(s)) return true;
492        }
493        if ("min-josm-version".equals(feature)) {
494            Float v = Cascade.convertTo(val, Float.class);
495            if (v != null) return Math.round(v) <= Version.getInstance().getVersion();
496        }
497        if ("max-josm-version".equals(feature)) {
498            Float v = Cascade.convertTo(val, Float.class);
499            if (v != null) return Math.round(v) >= Version.getInstance().getVersion();
500        }
501        return false;
502    }
503
504    @Override
505    public String toString() {
506        return Utils.join("\n", rules);
507    }
508}