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, 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}