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}