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.BufferedReader; 008import java.io.ByteArrayInputStream; 009import java.io.File; 010import java.io.IOException; 011import java.io.InputStream; 012import java.io.Reader; 013import java.io.StringReader; 014import java.lang.reflect.Field; 015import java.nio.charset.StandardCharsets; 016import java.util.ArrayList; 017import java.util.HashSet; 018import java.util.Iterator; 019import java.util.List; 020import java.util.Locale; 021import java.util.Map.Entry; 022import java.util.Set; 023import java.util.concurrent.locks.ReadWriteLock; 024import java.util.concurrent.locks.ReentrantReadWriteLock; 025import java.util.stream.Collectors; 026import java.util.zip.ZipEntry; 027import java.util.zip.ZipFile; 028 029import org.openstreetmap.josm.data.Version; 030import org.openstreetmap.josm.data.osm.IPrimitive; 031import org.openstreetmap.josm.data.osm.Node; 032import org.openstreetmap.josm.data.preferences.sources.SourceEntry; 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.StyleKeys; 038import org.openstreetmap.josm.gui.mappaint.StyleSetting; 039import org.openstreetmap.josm.gui.mappaint.StyleSetting.StyleSettingGroup; 040import org.openstreetmap.josm.gui.mappaint.StyleSettingFactory; 041import org.openstreetmap.josm.gui.mappaint.StyleSource; 042import org.openstreetmap.josm.gui.mappaint.mapcss.Selector.GeneralSelector; 043import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.MapCSSParser; 044import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.ParseException; 045import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.TokenMgrError; 046import org.openstreetmap.josm.gui.mappaint.styleelement.LineElement; 047import org.openstreetmap.josm.io.CachedFile; 048import org.openstreetmap.josm.io.UTFInputStreamReader; 049import org.openstreetmap.josm.tools.CheckParameterUtil; 050import org.openstreetmap.josm.tools.I18n; 051import org.openstreetmap.josm.tools.JosmRuntimeException; 052import org.openstreetmap.josm.tools.LanguageInfo; 053import org.openstreetmap.josm.tools.Logging; 054import org.openstreetmap.josm.tools.Utils; 055 056/** 057 * This is a mappaint style that is based on MapCSS rules. 058 */ 059public class MapCSSStyleSource extends StyleSource { 060 061 /** 062 * The accepted MIME types sent in the HTTP Accept header. 063 * @since 6867 064 */ 065 public static final String MAPCSS_STYLE_MIME_TYPES = 066 "text/x-mapcss, text/mapcss, text/css; q=0.9, text/plain; q=0.8, application/zip, application/octet-stream; q=0.5"; 067 068 /** 069 * all rules in this style file 070 */ 071 public final List<MapCSSRule> rules = new ArrayList<>(); 072 /** 073 * Index of rules in this style file 074 */ 075 private final MapCSSStyleIndex ruleIndex = new MapCSSStyleIndex(); 076 077 private Color backgroundColorOverride; 078 private String css; 079 private ZipFile zipFile; 080 081 private boolean removeAreaStylePseudoClass; 082 083 /** 084 * This lock prevents concurrent execution of {@link MapCSSRuleIndex#clear() } / 085 * {@link MapCSSRuleIndex#initIndex()} and {@link MapCSSRuleIndex#getRuleCandidates }. 086 * 087 * For efficiency reasons, these methods are synchronized higher up the 088 * stack trace. 089 */ 090 public static final ReadWriteLock STYLE_SOURCE_LOCK = new ReentrantReadWriteLock(); 091 092 /** 093 * Set of all supported MapCSS keys. 094 */ 095 static final Set<String> SUPPORTED_KEYS = new HashSet<>(); 096 static { 097 for (Field f : StyleKeys.class.getDeclaredFields()) { 098 try { 099 SUPPORTED_KEYS.add((String) f.get(null)); 100 if (!f.getName().toLowerCase(Locale.ENGLISH).replace('_', '-').equals(f.get(null))) { 101 throw new JosmRuntimeException(f.getName()); 102 } 103 } catch (IllegalArgumentException | IllegalAccessException ex) { 104 throw new JosmRuntimeException(ex); 105 } 106 } 107 for (LineElement.LineType lt : LineElement.LineType.values()) { 108 SUPPORTED_KEYS.add(lt.prefix + StyleKeys.COLOR); 109 SUPPORTED_KEYS.add(lt.prefix + StyleKeys.DASHES); 110 SUPPORTED_KEYS.add(lt.prefix + StyleKeys.DASHES_BACKGROUND_COLOR); 111 SUPPORTED_KEYS.add(lt.prefix + StyleKeys.DASHES_BACKGROUND_OPACITY); 112 SUPPORTED_KEYS.add(lt.prefix + StyleKeys.DASHES_OFFSET); 113 SUPPORTED_KEYS.add(lt.prefix + StyleKeys.LINECAP); 114 SUPPORTED_KEYS.add(lt.prefix + StyleKeys.LINEJOIN); 115 SUPPORTED_KEYS.add(lt.prefix + StyleKeys.MITERLIMIT); 116 SUPPORTED_KEYS.add(lt.prefix + StyleKeys.OFFSET); 117 SUPPORTED_KEYS.add(lt.prefix + StyleKeys.OPACITY); 118 SUPPORTED_KEYS.add(lt.prefix + StyleKeys.REAL_WIDTH); 119 SUPPORTED_KEYS.add(lt.prefix + StyleKeys.WIDTH); 120 } 121 } 122 123 /** 124 * Constructs a new, active {@link MapCSSStyleSource}. 125 * @param url URL that {@link org.openstreetmap.josm.io.CachedFile} understands 126 * @param name The name for this StyleSource 127 * @param shortdescription The title for that source. 128 */ 129 public MapCSSStyleSource(String url, String name, String shortdescription) { 130 super(url, name, shortdescription); 131 } 132 133 /** 134 * Constructs a new {@link MapCSSStyleSource} 135 * @param entry The entry to copy the data (url, name, ...) from. 136 */ 137 public MapCSSStyleSource(SourceEntry entry) { 138 super(entry); 139 } 140 141 /** 142 * <p>Creates a new style source from the MapCSS styles supplied in 143 * {@code css}</p> 144 * 145 * @param css the MapCSS style declaration. Must not be null. 146 * @throws IllegalArgumentException if {@code css} is null 147 */ 148 public MapCSSStyleSource(String css) { 149 super(null, null, null); 150 CheckParameterUtil.ensureParameterNotNull(css); 151 this.css = css; 152 } 153 154 @Override 155 public void loadStyleSource(boolean metadataOnly) { 156 STYLE_SOURCE_LOCK.writeLock().lock(); 157 try { 158 init(); 159 rules.clear(); 160 ruleIndex.clear(); 161 // remove "areaStyle" pseudo classes intended only for validator (causes StackOverflowError otherwise), see #16183 162 removeAreaStylePseudoClass = url == null || !url.contains("validator"); // resource://data/validator/ or xxx.validator.mapcss 163 try (InputStream in = getSourceInputStream()) { 164 try (Reader reader = new BufferedReader(UTFInputStreamReader.create(in))) { 165 // evaluate @media { ... } blocks 166 MapCSSParser preprocessor = new MapCSSParser(reader, MapCSSParser.LexicalState.PREPROCESSOR); 167 168 // do the actual mapcss parsing 169 try (Reader in2 = new StringReader(preprocessor.pp_root(this))) { 170 new MapCSSParser(in2, MapCSSParser.LexicalState.DEFAULT).sheet(this); 171 } 172 173 loadMeta(); 174 if (!metadataOnly) { 175 loadCanvas(); 176 loadSettings(); 177 } else { 178 rules.clear(); 179 } 180 } finally { 181 closeSourceInputStream(in); 182 } 183 } catch (IOException | IllegalArgumentException e) { 184 Logging.warn(tr("Failed to load Mappaint styles from ''{0}''. Exception was: {1}", url, e.toString())); 185 Logging.log(Logging.LEVEL_ERROR, e); 186 logError(e); 187 } catch (TokenMgrError e) { 188 Logging.warn(tr("Failed to parse Mappaint styles from ''{0}''. Error was: {1}", url, e.getMessage())); 189 Logging.error(e); 190 logError(e); 191 } catch (ParseException e) { 192 Logging.warn(tr("Failed to parse Mappaint styles from ''{0}''. Error was: {1}", url, e.getMessage())); 193 Logging.error(e); 194 logError(new ParseException(e.getMessage())); // allow e to be garbage collected, it links to the entire token stream 195 } 196 if (metadataOnly) { 197 return; 198 } 199 // optimization: filter rules for different primitive types 200 ruleIndex.buildIndex(rules.stream()); 201 loaded = true; 202 } finally { 203 STYLE_SOURCE_LOCK.writeLock().unlock(); 204 } 205 } 206 207 @Override 208 public InputStream getSourceInputStream() throws IOException { 209 if (css != null) { 210 return new ByteArrayInputStream(css.getBytes(StandardCharsets.UTF_8)); 211 } 212 CachedFile cf = getCachedFile(); 213 if (isZip) { 214 File file = cf.getFile(); 215 zipFile = new ZipFile(file, StandardCharsets.UTF_8); 216 zipIcons = file; 217 I18n.addTexts(zipIcons); 218 ZipEntry zipEntry = zipFile.getEntry(zipEntryPath); 219 return zipFile.getInputStream(zipEntry); 220 } else { 221 zipFile = null; 222 zipIcons = null; 223 return cf.getInputStream(); 224 } 225 } 226 227 @Override 228 @SuppressWarnings("resource") 229 public CachedFile getCachedFile() throws IOException { 230 return new CachedFile(url).setHttpAccept(MAPCSS_STYLE_MIME_TYPES); // NOSONAR 231 } 232 233 @Override 234 public void closeSourceInputStream(InputStream is) { 235 super.closeSourceInputStream(is); 236 if (isZip) { 237 Utils.close(zipFile); 238 } 239 } 240 241 /** 242 * load meta info from a selector "meta" 243 */ 244 private void loadMeta() { 245 Cascade c = constructSpecial(Selector.BASE_META); 246 String pTitle = c.get("title", null, String.class); 247 if (title == null) { 248 title = pTitle; 249 } 250 String pIcon = c.get("icon", null, String.class); 251 if (icon == null) { 252 icon = pIcon; 253 } 254 } 255 256 private void loadCanvas() { 257 Cascade c = constructSpecial(Selector.BASE_CANVAS); 258 backgroundColorOverride = c.get("fill-color", null, Color.class); 259 } 260 261 private static void loadSettings(MapCSSRule r, GeneralSelector gs, Environment env) { 262 if (gs.matchesConditions(env)) { 263 env.layer = null; 264 env.layer = gs.getSubpart().getId(env); 265 r.execute(env); 266 } 267 } 268 269 private void loadSettings() { 270 settings.clear(); 271 settingValues.clear(); 272 settingGroups.clear(); 273 MultiCascade mc = new MultiCascade(); 274 MultiCascade mcGroups = new MultiCascade(); 275 Node n = new Node(); 276 n.put("lang", LanguageInfo.getJOSMLocaleCode()); 277 // create a fake environment to read the meta data block 278 Environment env = new Environment(n, mc, "default", this); 279 Environment envGroups = new Environment(n, mcGroups, "default", this); 280 281 // Parse rules 282 for (MapCSSRule r : rules) { 283 final Selector gs = r.selectors.get(0); 284 if (gs instanceof GeneralSelector) { 285 if (Selector.BASE_SETTING.equals(gs.getBase())) { 286 loadSettings(r, ((GeneralSelector) gs), env); 287 } else if (Selector.BASE_SETTINGS.equals(gs.getBase())) { 288 loadSettings(r, ((GeneralSelector) gs), envGroups); 289 } 290 } 291 } 292 // Load groups 293 for (Entry<String, Cascade> e : mcGroups.getLayers()) { 294 if ("default".equals(e.getKey())) { 295 Logging.warn("settings requires layer identifier e.g. 'settings::settings_group {...}'"); 296 continue; 297 } 298 settingGroups.put(StyleSettingGroup.create(e.getValue(), this, e.getKey()), new ArrayList<>()); 299 } 300 // Load settings 301 for (Entry<String, Cascade> e : mc.getLayers()) { 302 if ("default".equals(e.getKey())) { 303 Logging.warn("setting requires layer identifier e.g. 'setting::my_setting {...}'"); 304 continue; 305 } 306 Cascade c = e.getValue(); 307 StyleSetting set = StyleSettingFactory.create(c, this, e.getKey()); 308 if (set != null) { 309 settings.add(set); 310 settingValues.put(e.getKey(), set.getValue()); 311 String groupId = c.get("group", null, String.class); 312 if (groupId != null) { 313 final StyleSettingGroup group = settingGroups.keySet().stream() 314 .filter(g -> g.key.equals(groupId)) 315 .findAny() 316 .orElseThrow(() -> new IllegalArgumentException("Unknown settings group: " + groupId)); 317 settingGroups.get(group).add(set); 318 } 319 } 320 } 321 settings.sort(null); 322 } 323 324 private Cascade constructSpecial(String type) { 325 326 MultiCascade mc = new MultiCascade(); 327 Node n = new Node(); 328 String code = LanguageInfo.getJOSMLocaleCode(); 329 n.put("lang", code); 330 // create a fake environment to read the meta data block 331 Environment env = new Environment(n, mc, "default", this); 332 333 for (MapCSSRule r : rules) { 334 final boolean matches = r.selectors.stream().anyMatch(gs -> gs instanceof GeneralSelector 335 && gs.getBase().equals(type) 336 && ((GeneralSelector) gs).matchesConditions(env)); 337 if (matches) { 338 r.execute(env); 339 } 340 } 341 return mc.getCascade("default"); 342 } 343 344 @Override 345 public Color getBackgroundColorOverride() { 346 return backgroundColorOverride; 347 } 348 349 @Override 350 public void apply(MultiCascade mc, IPrimitive osm, double scale, boolean pretendWayIsClosed) { 351 352 Environment env = new Environment(osm, mc, null, this); 353 // the declaration indices are sorted, so it suffices to save the last used index 354 int lastDeclUsed = -1; 355 356 Iterator<MapCSSRule> candidates = ruleIndex.getRuleCandidates(osm); 357 while (candidates.hasNext()) { 358 MapCSSRule r = candidates.next(); 359 for (Selector s : r.selectors) { 360 env.clearSelectorMatchingInformation(); 361 env.layer = s.getSubpart().getId(env); 362 String sub = env.layer; 363 if (!s.matches(env)) { // as side effect env.parent will be set (if s is a child selector) 364 continue; 365 } 366 if (s.getRange().contains(scale)) { 367 mc.range = Range.cut(mc.range, s.getRange()); 368 } else { 369 mc.range = mc.range.reduceAround(scale, s.getRange()); 370 continue; 371 } 372 373 if (r.declaration.idx == lastDeclUsed) 374 continue; // don't apply one declaration more than once 375 lastDeclUsed = r.declaration.idx; 376 if ("*".equals(sub)) { 377 for (Entry<String, Cascade> entry : mc.getLayers()) { 378 env.layer = entry.getKey(); 379 if ("*".equals(env.layer)) { 380 continue; 381 } 382 r.execute(env); 383 } 384 } 385 env.layer = sub; 386 r.execute(env); 387 } 388 } 389 } 390 391 /** 392 * Evaluate a supports condition 393 * @param feature The feature to evaluate for 394 * @param val The additional parameter passed to evaluate 395 * @return <code>true</code> if JSOM supports that feature 396 */ 397 public boolean evalSupportsDeclCondition(String feature, Object val) { 398 if (feature == null) return false; 399 if (SUPPORTED_KEYS.contains(feature)) return true; 400 switch (feature) { 401 case "user-agent": 402 String s = Cascade.convertTo(val, String.class); 403 return "josm".equals(s); 404 case "min-josm-version": 405 Float min = Cascade.convertTo(val, Float.class); 406 return min != null && Math.round(min) <= Version.getInstance().getVersion(); 407 case "max-josm-version": 408 Float max = Cascade.convertTo(val, Float.class); 409 return max != null && Math.round(max) >= Version.getInstance().getVersion(); 410 default: 411 return false; 412 } 413 } 414 415 /** 416 * Removes "meta" rules. Not needed for validator. 417 * @since 13633 418 */ 419 public void removeMetaRules() { 420 rules.removeIf(x -> x.selectors.get(0) instanceof GeneralSelector && Selector.BASE_META.equals(x.selectors.get(0).getBase())); 421 } 422 423 /** 424 * Whether to remove "areaStyle" pseudo classes. Only for use in MapCSSParser! 425 * @return whether to remove "areaStyle" pseudo classes 426 */ 427 public boolean isRemoveAreaStylePseudoClass() { 428 return removeAreaStylePseudoClass; 429 } 430 431 @Override 432 public String toString() { 433 // Avoids ConcurrentModificationException 434 return new ArrayList<>(rules).stream().map(MapCSSRule::toString).collect(Collectors.joining("\n")); 435 } 436}