001/** 002 * Copyright (C) 2006-2025 Talend Inc. - www.talend.com 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.talend.sdk.component.server.service; 017 018import static java.util.Locale.ROOT; 019import static java.util.Optional.of; 020import static java.util.Optional.ofNullable; 021import static java.util.function.Function.identity; 022import static java.util.stream.Collectors.toList; 023 024import java.io.BufferedInputStream; 025import java.io.ByteArrayOutputStream; 026import java.io.IOException; 027import java.io.InputStream; 028import java.util.Collection; 029import java.util.List; 030import java.util.Optional; 031import java.util.concurrent.ConcurrentHashMap; 032import java.util.concurrent.ConcurrentMap; 033 034import javax.annotation.PostConstruct; 035import javax.enterprise.context.ApplicationScoped; 036import javax.inject.Inject; 037 038import org.talend.sdk.component.container.Container; 039import org.talend.sdk.component.server.configuration.ComponentServerConfiguration; 040 041import lombok.Data; 042import lombok.extern.slf4j.Slf4j; 043 044@Slf4j 045@ApplicationScoped 046public class IconResolver { 047 048 @Inject 049 private ComponentServerConfiguration componentServerConfiguration; 050 051 private boolean supportsSvg; 052 053 private String defaultTheme; 054 055 private Boolean isThemeSupported; 056 057 private Boolean isLegacyIconsSupported; 058 059 private List<String> patterns; 060 061 @PostConstruct 062 protected void init() { 063 supportsSvg = studioSupportsSvgOrTrue() 064 && componentServerConfiguration.getIconExtensions().stream().anyMatch(it -> it.endsWith(".svg")); 065 isThemeSupported = componentServerConfiguration.getSupportIconTheme(); 066 isLegacyIconsSupported = componentServerConfiguration.getSupportLegacyIcons(); 067 defaultTheme = componentServerConfiguration.getIconDefaultTheme(); 068 patterns = isSupportsSvg() ? componentServerConfiguration.getIconExtensions() 069 : componentServerConfiguration 070 .getIconExtensions() 071 .stream() 072 .filter(it -> !it.endsWith(".svg")) 073 .collect(toList()); 074 log.info("[IconResolver] SVG supported: {}, patterns: {}.", isSupportsSvg(), patterns); 075 } 076 077 private Boolean studioSupportsSvgOrTrue() { 078 if (System.getProperty("talend.studio.version") != null) { 079 return Boolean.getBoolean("talend.component.server.icon.svg.support"); 080 } 081 return true; 082 } 083 084 protected boolean isSupportsSvg() { 085 return supportsSvg; 086 } 087 088 protected Collection<String> getExtensionPreferences() { 089 return patterns; 090 } 091 092 /** 093 * IMPORTANT: the strategy moved to the configuration, high level we want something in this spirit: 094 * 095 * The lookup strategy of an icon is the following one: 096 * 1. Check in the server classpath in icons/override/${icon}_icon32.png 097 * (optionally icons/override/${icon}.svg if in the preferences), 098 * 2. Check in the family classloader the following names ${icon}_icon32.png, icons/${icon}_icon32.png, ... 099 * 3. Check in the server classloader the following names ${icon}_icon32.png, icons/${icon}_icon32.png, ... 100 * 101 * This enables to 102 * 1. override properly the icons (1), 103 * 2. provide them in the family (2) and 104 * 3. fallback on built-in icons if needed (3). 105 * 106 * Theme and legacy icons support: 107 * If theme is activated the path will be prefixed by its theme: icons/dark/${icon}.svg, icons/light/${icon}.svg. 108 * Also, if icon is not found in theme and legacy icons support is activated, legacy icons lookup will be applied. 109 * Otherwise, theme deactivated, legacy icons lookup will be applied. 110 * 111 * @param container the component family container. 112 * @param icon the icon to look up. 113 * @param theme the theme of icon to look up. 114 * @return the icon if found. 115 */ 116 public Icon resolve(final Container container, final String icon, final String theme) { 117 if (icon == null) { 118 return null; 119 } 120 121 Cache cache = container.get(Cache.class); 122 if (cache == null) { 123 synchronized (container) { 124 cache = container.get(Cache.class); 125 if (cache == null) { 126 cache = new Cache(); 127 container.set(Cache.class, cache); 128 } 129 } 130 } 131 final ClassLoader appLoader = Thread.currentThread().getContextClassLoader(); 132 if (isThemeSupported) { 133 final String appliedTheme = theme != null ? theme : defaultTheme; 134 final String themedIcon = appliedTheme + "/" + icon; 135 Icon themed = cache.icons 136 .computeIfAbsent(themedIcon, 137 k -> ofNullable(getOverridenIcon(icon, appLoader) 138 .orElseGet(() -> doLoad(container.getLoader(), themedIcon, appliedTheme) 139 .orElseGet( 140 () -> doLoad(appLoader, themedIcon, appliedTheme).orElse(null))))) 141 .orElse(null); 142 if (themed == null && isLegacyIconsSupported) { 143 themed = cache.icons 144 .computeIfAbsent(icon, 145 k -> ofNullable(doLoad(container.getLoader(), icon, "") 146 .orElseGet(() -> doLoad(appLoader, icon, "") 147 .orElse(null)))) 148 .orElse(null); 149 } 150 return themed; 151 } else { 152 return cache.icons 153 .computeIfAbsent(icon, 154 k -> ofNullable(getOverridenIcon(icon, appLoader) 155 .orElseGet(() -> doLoad(container.getLoader(), icon, "") 156 .orElseGet(() -> doLoad(appLoader, icon, "").orElse(null))))) 157 .orElse(null); 158 } 159 } 160 161 private Optional<Icon> getOverridenIcon(final String icon, final ClassLoader appLoader) { 162 Icon result = null; 163 if (isSupportsSvg()) { 164 result = loadIcon(appLoader, "icons/override/" + icon + ".svg", "").orElse(null); 165 } 166 if (result == null) { 167 return loadIcon(appLoader, "icons/override/" + icon + "_icon32.png", ""); 168 } 169 return of(result); 170 } 171 172 public Optional<Icon> doLoad(final ClassLoader loader, final String icon, final String theme) { 173 return getExtensionPreferences() 174 .stream() 175 .map(ext -> String.format(ext, icon)) 176 .map(path -> loadIcon(loader, path, theme)) 177 .filter(Optional::isPresent) 178 .findFirst() 179 .flatMap(identity()); 180 } 181 182 private Optional<Icon> loadIcon(final ClassLoader loader, final String path, final String theme) { 183 return ofNullable(loader.getResourceAsStream(path)) 184 .map(resource -> new Icon(getType(path.toLowerCase(ROOT)), toBytes(resource), theme)); 185 } 186 187 private String getType(final String path) { 188 if (path.endsWith(".png")) { 189 return "image/png"; 190 } 191 if (path.endsWith(".svg")) { 192 return "image/svg+xml"; 193 } 194 throw new IllegalArgumentException("Unsupported icon type: " + path); 195 } 196 197 public static class Cache { 198 199 private final ConcurrentMap<String, Optional<Icon>> icons = new ConcurrentHashMap<>(); 200 } 201 202 @Data 203 public static class Icon { 204 205 private final String type; 206 207 private final byte[] bytes; 208 209 private final String theme; 210 } 211 212 private byte[] toBytes(final InputStream resource) { 213 try (final BufferedInputStream stream = new BufferedInputStream(resource)) { 214 final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(stream.available()); 215 final byte[] buffer = new byte[1024]; 216 int read; 217 while ((read = stream.read(buffer, 0, buffer.length)) >= 0) { 218 if (read > 0) { 219 byteArrayOutputStream.write(buffer, 0, read); 220 } 221 } 222 return byteArrayOutputStream.toByteArray(); 223 } catch (final IOException e) { 224 throw new IllegalStateException(e); 225 } 226 } 227}