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