diff --git a/spring-boot/src/main/java/org/springframework/boot/ImageBanner.java b/spring-boot/src/main/java/org/springframework/boot/ImageBanner.java new file mode 100644 index 0000000000..41f8a1f245 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/ImageBanner.java @@ -0,0 +1,249 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot; + +import java.awt.Color; +import java.awt.Image; + +import java.awt.image.BufferedImage; + +import java.io.PrintStream; + +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; + +import javax.imageio.ImageIO; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.boot.ansi.AnsiPropertySource; +import org.springframework.core.env.Environment; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertyResolver; +import org.springframework.core.env.PropertySourcesPropertyResolver; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; + +/** + * Banner implementation that prints ASCII art generated from an image resource + * {@link Resource}. + * + * @author Craig Burke + */ +public class ImageBanner implements Banner { + + private static final Log log = LogFactory.getLog(ImageBanner.class); + + private static final double RED_WEIGHT = 0.2126d; + private static final double GREEN_WEIGHT = 0.7152d; + private static final double BLUE_WEIGHT = 0.0722d; + + private static final int DEFAULT_MAX_WIDTH = 72; + private static final double DEFAULT_ASPECT_RATIO = 0.5d; + private static final boolean DEFAULT_DARK = false; + + private Resource image; + private Map colors = new HashMap(); + + public ImageBanner(Resource image) { + Assert.notNull(image, "Image must not be null"); + Assert.isTrue(image.exists(), "Image must exist"); + this.image = image; + colorsInit(); + } + + private void colorsInit() { + this.colors.put("BLACK", new Color(0, 0, 0)); + this.colors.put("RED", new Color(170, 0, 0)); + this.colors.put("GREEN", new Color(0, 170, 0)); + this.colors.put("YELLOW", new Color(170, 85, 0)); + this.colors.put("BLUE", new Color(0, 0, 170)); + this.colors.put("MAGENTA", new Color(170, 0, 170)); + this.colors.put("CYAN", new Color(0, 170, 170)); + this.colors.put("WHITE", new Color(170, 170, 170)); + + this.colors.put("BRIGHT_BLACK", new Color(85, 85, 85)); + this.colors.put("BRIGHT_RED", new Color(255, 85, 85)); + this.colors.put("BRIGHT_GREEN", new Color(85, 255, 85)); + this.colors.put("BRIGHT_YELLOW", new Color(255, 255, 85)); + this.colors.put("BRIGHT_BLUE", new Color(85, 85, 255)); + this.colors.put("BRIGHT_MAGENTA", new Color(255, 85, 255)); + this.colors.put("BRIGHT_CYAN", new Color(85, 255, 255)); + this.colors.put("BRIGHT_WHITE", new Color(255, 255, 255)); + } + + @Override + public void printBanner(Environment environment, Class sourceClass, PrintStream out) { + String headlessProperty = System.getProperty("java.awt.headless"); + try { + System.setProperty("java.awt.headless", "true"); + BufferedImage sourceImage = ImageIO.read(this.image.getInputStream()); + + int maxWidth = environment.getProperty("banner.image.max-width", + Integer.class, DEFAULT_MAX_WIDTH); + Double aspectRatio = environment.getProperty("banner.image.aspect-ratio", + Double.class, DEFAULT_ASPECT_RATIO); + boolean invert = environment.getProperty("banner.image.dark", Boolean.class, + DEFAULT_DARK); + + BufferedImage resizedImage = resizeImage(sourceImage, maxWidth, aspectRatio); + String banner = imageToBanner(resizedImage, invert); + + PropertyResolver ansiResolver = getAnsiResolver(); + banner = ansiResolver.resolvePlaceholders(banner); + out.println(banner); + } + catch (Exception ex) { + log.warn("Image banner not printable: " + this.image + " (" + ex.getClass() + + ": '" + ex.getMessage() + "')", ex); + } + finally { + System.setProperty("java.awt.headless", headlessProperty); + } + } + + private PropertyResolver getAnsiResolver() { + MutablePropertySources sources = new MutablePropertySources(); + sources.addFirst(new AnsiPropertySource("ansi", true)); + return new PropertySourcesPropertyResolver(sources); + } + + private String imageToBanner(BufferedImage image, boolean dark) { + StringBuilder banner = new StringBuilder(); + + for (int y = 0; y < image.getHeight(); y++) { + if (dark) { + banner.append("${AnsiBackground.BLACK}"); + } + else { + banner.append("${AnsiBackground.DEFAULT}"); + } + for (int x = 0; x < image.getWidth(); x++) { + Color color = new Color(image.getRGB(x, y), false); + banner.append(getFormatString(color, dark)); + } + if (dark) { + banner.append("${AnsiBackground.DEFAULT}"); + } + banner.append("${AnsiColor.DEFAULT}\n"); + } + + return banner.toString(); + } + + protected String getFormatString(Color color, boolean dark) { + String matchedColorName = null; + Double minColorDistance = null; + + for (Entry colorOption : this.colors.entrySet()) { + double distance = getColorDistance(color, colorOption.getValue()); + + if (minColorDistance == null || distance < minColorDistance) { + minColorDistance = distance; + matchedColorName = colorOption.getKey(); + } + } + + return "${AnsiColor." + matchedColorName + "}" + getAsciiCharacter(color, dark); + } + + private static int getLuminance(Color color, boolean inverse) { + double red = color.getRed(); + double green = color.getGreen(); + double blue = color.getBlue(); + + double luminance; + + if (inverse) { + luminance = (RED_WEIGHT * (255.0d - red)) + (GREEN_WEIGHT * (255.0d - green)) + + (BLUE_WEIGHT * (255.0d - blue)); + } + else { + luminance = (RED_WEIGHT * red) + (GREEN_WEIGHT * green) + + (BLUE_WEIGHT * blue); + } + + return (int) Math.ceil((luminance / 255.0d) * 100); + } + + private static char getAsciiCharacter(Color color, boolean dark) { + double luminance = getLuminance(color, dark); + + if (luminance >= 90) { + return ' '; + } + else if (luminance >= 80) { + return '.'; + } + else if (luminance >= 70) { + return '*'; + } + else if (luminance >= 60) { + return ':'; + } + else if (luminance >= 50) { + return 'o'; + } + else if (luminance >= 40) { + return '&'; + } + else if (luminance >= 30) { + return '8'; + } + else if (luminance >= 20) { + return '#'; + } + else { + return '@'; + } + } + + private static double getColorDistance(Color color1, Color color2) { + double redDelta = (color1.getRed() - color2.getRed()) * RED_WEIGHT; + double greenDelta = (color1.getGreen() - color2.getGreen()) * GREEN_WEIGHT; + double blueDelta = (color1.getBlue() - color2.getBlue()) * BLUE_WEIGHT; + + return Math.pow(redDelta, 2.0d) + Math.pow(greenDelta, 2.0d) + + Math.pow(blueDelta, 2.0d); + } + + private static BufferedImage resizeImage(BufferedImage sourceImage, int maxWidth, + double aspectRatio) { + int width; + double resizeRatio; + if (sourceImage.getWidth() > maxWidth) { + resizeRatio = (double) maxWidth / (double) sourceImage.getWidth(); + width = maxWidth; + } + else { + resizeRatio = 1.0d; + width = sourceImage.getWidth(); + } + + int height = (int) (Math.ceil(resizeRatio * aspectRatio + * (double) sourceImage.getHeight())); + Image image = sourceImage.getScaledInstance(width, height, Image.SCALE_DEFAULT); + + BufferedImage resizedImage = new BufferedImage(image.getWidth(null), + image.getHeight(null), BufferedImage.TYPE_INT_RGB); + + resizedImage.getGraphics().drawImage(image, 0, 0, null); + return resizedImage; + } +} diff --git a/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java b/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java index 4343af79c3..afbb224c5b 100644 --- a/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java +++ b/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java @@ -140,6 +140,7 @@ import org.springframework.web.context.support.StandardServletEnvironment; * @author Christian Dupuis * @author Stephane Nicoll * @author Jeremy Rickard + * @author Craig Burke * @see #run(Object, String[]) * @see #run(Object[], String[]) * @see #SpringApplication(Object...) @@ -569,9 +570,27 @@ public class SpringApplication { if (this.banner != null) { return this.banner; } + + Resource image = getBannerImage(environment, resourceLoader); + if (image.exists()) { + return new ImageBanner(image); + } + return DEFAULT_BANNER; } + private Resource getBannerImage(Environment environment, ResourceLoader resourceLoader) { + String imageLocation = environment.getProperty("banner.image", "banner.gif"); + Resource image = resourceLoader.getResource(imageLocation); + if (!image.exists()) { + image = resourceLoader.getResource("banner.jpg"); + } + if (!image.exists()) { + image = resourceLoader.getResource("banner.png"); + } + return image; + } + private String createStringFromBanner(Banner banner, Environment environment) throws UnsupportedEncodingException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); diff --git a/spring-boot/src/test/java/org/springframework/boot/ImageBannerTests.java b/spring-boot/src/test/java/org/springframework/boot/ImageBannerTests.java new file mode 100644 index 0000000000..4d5460ed0c --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/ImageBannerTests.java @@ -0,0 +1,239 @@ +/* + * Copyright 2012-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.boot.ansi.AnsiBackground; +import org.springframework.boot.ansi.AnsiColor; +import org.springframework.boot.ansi.AnsiElement; +import org.springframework.boot.ansi.AnsiOutput; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.mock.env.MockEnvironment; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.startsWith; + +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link ImageBanner}. + * + * @author Craig Burke + */ +public class ImageBannerTests { + + private static final String IMAGE_BANNER_BLACK_AND_WHITE = "banners/black-and-white.gif"; + private static final String IMAGE_BANNER_LARGE = "banners/large.gif"; + private static final String IMAGE_BANNER_ALL_COLORS = "banners/colors.gif"; + private static final String IMAGE_BANNER_GRADIENT = "banners/gradient.gif"; + + private static final String BACKGROUND_DEFAULT_ANSI = getAnsiOutput(AnsiBackground.DEFAULT); + private static final String BACKGROUND_DARK_ANSI = getAnsiOutput(AnsiBackground.BLACK); + + private static final char HIGH_LUMINANCE_CHARACTER = ' '; + private static final char LOW_LUMINANCE_CHARACTER = '@'; + + private static Map properties; + + @Before + public void setup() { + AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS); + properties = new HashMap(); + } + + @Test + public void renderDefaultBackground() { + String banner = printBanner(IMAGE_BANNER_BLACK_AND_WHITE); + + assertThat(banner, startsWith(BACKGROUND_DEFAULT_ANSI)); + } + + @Test + public void renderDarkBackground() { + setDark(true); + String banner = printBanner(IMAGE_BANNER_BLACK_AND_WHITE); + + assertThat(banner, startsWith(BACKGROUND_DARK_ANSI)); + } + + @Test + public void renderWhiteCharactersWithColors() { + String banner = printBanner(IMAGE_BANNER_BLACK_AND_WHITE); + String expectedFirstLine = getAnsiOutput(AnsiColor.BRIGHT_WHITE) + + HIGH_LUMINANCE_CHARACTER; + + assertThat(banner, containsString(expectedFirstLine)); + } + + @Test + public void renderWhiteCharactersOnDarkBackground() { + setDark(true); + String banner = printBanner(IMAGE_BANNER_BLACK_AND_WHITE); + String expectedFirstLine = getAnsiOutput(AnsiColor.BRIGHT_WHITE) + + LOW_LUMINANCE_CHARACTER; + + assertThat(banner, containsString(expectedFirstLine)); + } + + @Test + public void renderBlackCharactersOnDefaultBackground() { + String banner = printBanner(IMAGE_BANNER_BLACK_AND_WHITE); + String blackCharacter = getAnsiOutput(AnsiColor.BLACK) + LOW_LUMINANCE_CHARACTER; + + assertThat(banner, containsString(blackCharacter)); + } + + @Test + public void renderBlackCharactersOnDarkBackground() { + setDark(true); + String banner = printBanner(IMAGE_BANNER_BLACK_AND_WHITE); + String blackCharacter = getAnsiOutput(AnsiColor.BLACK) + HIGH_LUMINANCE_CHARACTER; + + assertThat(banner, containsString(blackCharacter)); + } + + @Test + public void renderBannerWithAllColors() { + String banner = printBanner(IMAGE_BANNER_ALL_COLORS); + + assertThat("Banner contains BLACK", banner, + containsString(getAnsiOutput(AnsiColor.BLACK))); + assertThat("Banner contains RED", banner, + containsString(getAnsiOutput(AnsiColor.RED))); + assertThat("Banner contains GREEN", banner, + containsString(getAnsiOutput(AnsiColor.GREEN))); + assertThat("Banner contains YELLOW", banner, + containsString(getAnsiOutput(AnsiColor.YELLOW))); + assertThat("Banner contains BLUE", banner, + containsString(getAnsiOutput(AnsiColor.BLUE))); + assertThat("Banner contains MAGENTA", banner, + containsString(getAnsiOutput(AnsiColor.MAGENTA))); + assertThat("Banner contains CYAN", banner, + containsString(getAnsiOutput(AnsiColor.CYAN))); + assertThat("Banner contains WHITE", banner, + containsString(getAnsiOutput(AnsiColor.WHITE))); + + assertThat("Banner contains BRIGHT_BLACK", banner, + containsString(getAnsiOutput(AnsiColor.BRIGHT_BLACK))); + assertThat("Banner contains BRIGHT_RED", banner, + containsString(getAnsiOutput(AnsiColor.BRIGHT_RED))); + assertThat("Banner contains BRIGHT_GREEN", banner, + containsString(getAnsiOutput(AnsiColor.BRIGHT_GREEN))); + assertThat("Banner contains BRIGHT_YELLOW", banner, + containsString(getAnsiOutput(AnsiColor.BRIGHT_YELLOW))); + assertThat("Banner contains BRIGHT_BLUE", banner, + containsString(getAnsiOutput(AnsiColor.BRIGHT_BLUE))); + assertThat("Banner contains BRIGHT_MAGENTA", banner, + containsString(getAnsiOutput(AnsiColor.BRIGHT_MAGENTA))); + assertThat("Banner contains BRIGHT_CYAN", banner, + containsString(getAnsiOutput(AnsiColor.BRIGHT_CYAN))); + assertThat("Banner contains BRIGHT_WHITE", banner, + containsString(getAnsiOutput(AnsiColor.BRIGHT_WHITE))); + } + + @Test + public void renderSimpleGradient() { + AnsiOutput.setEnabled(AnsiOutput.Enabled.NEVER); + String banner = printBanner(IMAGE_BANNER_GRADIENT); + String expectedResult = "@#8&o:*. "; + + assertThat(banner, startsWith(expectedResult)); + } + + @Test + public void renderBannerWithDefaultAspectRatio() { + String banner = printBanner(IMAGE_BANNER_BLACK_AND_WHITE); + int bannerHeight = getBannerHeight(banner); + + assertThat(bannerHeight, equalTo(2)); + } + + @Test + public void renderBannerWithCustomAspectRatio() { + setAspectRatio(1.0d); + String banner = printBanner(IMAGE_BANNER_BLACK_AND_WHITE); + int bannerHeight = getBannerHeight(banner); + + assertThat(bannerHeight, equalTo(4)); + } + + @Test + public void renderLargeBanner() { + String banner = printBanner(IMAGE_BANNER_LARGE); + int bannerWidth = getBannerWidth(banner); + + assertThat(bannerWidth, equalTo(72)); + } + + @Test + public void renderLargeBannerWithACustomWidth() { + setMaxWidth(60); + String banner = printBanner(IMAGE_BANNER_LARGE); + int bannerWidth = getBannerWidth(banner); + + assertThat(bannerWidth, equalTo(60)); + } + + private int getBannerHeight(String banner) { + return banner.split("\n").length; + } + + private int getBannerWidth(String banner) { + String strippedBanner = banner.replaceAll("\u001B\\[.*?m", ""); + String firstLine = strippedBanner.split("\n")[0]; + return firstLine.length(); + } + + private static String getAnsiOutput(AnsiElement ansi) { + return "\u001B[" + ansi.toString() + "m"; + } + + private void setDark(boolean dark) { + properties.put("banner.image.dark", dark); + } + + private void setMaxWidth(int maxWidth) { + properties.put("banner.image.max-width", maxWidth); + } + + private void setAspectRatio(double aspectRatio) { + properties.put("banner.image.aspect-ratio", aspectRatio); + } + + private String printBanner(String imagePath) { + Resource image = new ClassPathResource(imagePath); + ImageBanner banner = new ImageBanner(image); + ConfigurableEnvironment environment = new MockEnvironment(); + environment.getPropertySources().addLast( + new MapPropertySource("testConfig", properties)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + banner.printBanner(environment, getClass(), new PrintStream(out)); + return out.toString(); + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java b/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java index 11444ca2c3..644a1e0e1e 100644 --- a/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java +++ b/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java @@ -19,8 +19,10 @@ package org.springframework.boot; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; @@ -66,6 +68,7 @@ import org.springframework.core.env.MapPropertySource; import org.springframework.core.env.PropertySource; import org.springframework.core.env.StandardEnvironment; import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.test.context.support.TestPropertySourceUtils; import org.springframework.util.StringUtils; @@ -90,6 +93,7 @@ import static org.mockito.Mockito.verify; * @author Christian Dupuis * @author Stephane Nicoll * @author Jeremy Rickard + * @author Craig Burke */ public class SpringApplicationTests { @@ -191,6 +195,32 @@ public class SpringApplicationTests { .startsWith(String.format("Running a Test!%n%n123456")); } + @Test + public void textBannerTakesPrecedence() throws Exception { + SpringApplication application = new SpringApplication(ExampleConfig.class); + BannerResourceLoaderStub resourceLoader = new BannerResourceLoaderStub(); + resourceLoader.addResource("banner.gif", "banners/black-and-white.gif"); + resourceLoader.addResource("banner.txt", "banners/foobar.txt"); + + application.setWebEnvironment(false); + application.setResourceLoader(resourceLoader); + application.run(); + + assertThat(this.output.toString()).startsWith("Foo Bar"); + } + + @Test + public void imageBannerLoads() throws Exception { + SpringApplication application = new SpringApplication(ExampleConfig.class); + BannerResourceLoaderStub resourceLoader = new BannerResourceLoaderStub(); + resourceLoader.addResource("banner.gif", "banners/black-and-white.gif"); + application.setWebEnvironment(false); + application.setResourceLoader(resourceLoader); + application.run(); + + assertThat(this.output.toString()).startsWith("@"); + } + @Test public void logsNoActiveProfiles() throws Exception { SpringApplication application = new SpringApplication(ExampleConfig.class); @@ -1089,4 +1119,31 @@ public class SpringApplicationTests { } } + + private static class BannerResourceLoaderStub extends DefaultResourceLoader { + + private Map resources = new HashMap(); + Resource notFoundResource; + + BannerResourceLoaderStub() { + this.notFoundResource = super.getResource("classpath:foo/bar/foobar"); + assert !this.notFoundResource.exists(); + } + + public void addResource(String file, String realPath) { + this.resources.put(file, realPath); + } + + @Override + public Resource getResource(String s) { + if (this.resources.containsKey(s)) { + return super.getResource("classpath:" + this.resources.get(s)); + } + else { + return this.notFoundResource; + } + } + + } + } diff --git a/spring-boot/src/test/resources/banners/black-and-white.gif b/spring-boot/src/test/resources/banners/black-and-white.gif new file mode 100644 index 0000000000..745fd25384 Binary files /dev/null and b/spring-boot/src/test/resources/banners/black-and-white.gif differ diff --git a/spring-boot/src/test/resources/banners/colors.gif b/spring-boot/src/test/resources/banners/colors.gif new file mode 100644 index 0000000000..d6e00226bf Binary files /dev/null and b/spring-boot/src/test/resources/banners/colors.gif differ diff --git a/spring-boot/src/test/resources/banners/foobar.txt b/spring-boot/src/test/resources/banners/foobar.txt new file mode 100644 index 0000000000..67d8e4dbe0 --- /dev/null +++ b/spring-boot/src/test/resources/banners/foobar.txt @@ -0,0 +1 @@ +Foo Bar \ No newline at end of file diff --git a/spring-boot/src/test/resources/banners/gradient.gif b/spring-boot/src/test/resources/banners/gradient.gif new file mode 100644 index 0000000000..607269f21f Binary files /dev/null and b/spring-boot/src/test/resources/banners/gradient.gif differ diff --git a/spring-boot/src/test/resources/banners/large.gif b/spring-boot/src/test/resources/banners/large.gif new file mode 100644 index 0000000000..e1ebd0c86e Binary files /dev/null and b/spring-boot/src/test/resources/banners/large.gif differ