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}