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