/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.jooby.assets;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Element;
import org.jsoup.parser.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.typesafe.config.Config;
import javaslang.Lazy;
import javaslang.Tuple;
import javaslang.Tuple2;
/**
* <h1>svg-symbol</h1>
* <p>
* SVG <code>symbol</code> for icons: merge svg files from a folder and generates a
* <code>sprite.svg</code> and <code>sprite.css</code> files.
* </p>
*
* <h2>usage</h2>
*
* <pre>{@code
* assets {
* fileset {
* sprite: svg-symbol
* }
*
* svg-symbol {
* input: "images/svg"
* }
* }
* }</pre>
*
* <p>
* Previous example looks for <code>*.svg</code> files inside the <code>images/svg</code> folder and
* generate a <code>sprite.svg</code> and <code>sprite.css</code> files.
* </p>
*
* <p>
* You can display the svg icons using id reference:
* </p>
*
* <pre>{@code
* <svg>
* <use xlink:href="#approved" />
* </svg>
* }</pre>
*
* <p>
* This technique is described here:
* <a href="https://css-tricks.com/svg-symbol-good-choice-icons">SVG symbol a Good Choice for
* Icons</a>
* </p>
*
* <h2>options</h2>
*
* <h3>output</h3>
* <p>
* Defines where to write the <code>svg</code> and <code>css</code> files. Default value is:
* <code>sprite</code>.
* </p>
*
* <pre>{@code
* svg-symbol {
* output: "folder/symbols"
* }
* }</pre>
*
* <p>
* There are two more specific output options: <code>svg.output</code> and <code>css.output</code>
* if any of these options are present the <code>output</code> option is ignored:
* </p>
*
* <pre>{@code
* svg-symbol {
* css {
* output: "css/sprite.css"
* },
* svg {
* output: "img/sprite.svg"
* }
* }
* }</pre>
*
* <h3>id prefix and suffix</h3>
* <p>
* ID is generated from <code>svg file names</code>. These options prepend or append something to
* the generated id.
* </p>
*
* <pre>{@code
* svg-symbol {
* output: "sprite"
* id {
* prefix: "icon-"
* }
* }
* }</pre>
*
* <p>
* Generates IDs like: <code>icon-approved</code>, while:
* </p>
*
* <pre>{@code
* svg-symbol {
* output: "sprite"
* id {
* suffix: "-icon"
* }
* }
* }</pre>
*
* <p>
* Generates IDs like: <code>approved-icon</code>
* </p>
*
* <h3>css prefix</h3>
* <p>
* Prepend a string to a generated css class. Here is the css class for <code>approved.svg</code>:
* </p>
*
* <pre>{@code
* .approved {
* width: 18px;
* height: 18px;
* }
* }</pre>
*
* <p>
* If we set a <code>svg</code> css prefix:
*
* <pre>{@code
* {
* svg-symbol: {
* css {
* prefix: "svg"
* }
* }
* }
* }</pre>
*
* <p>
* The generated css class will be:
* <pre>{@code
* svg.approved {
* width: 18px;
* height: 18px;
* }
* }</pre>
*
* <p>
* This option is useful for generating more specific css class selectors.
* </p>
*
* @author edgar
*/
public class SvgSymbol extends AssetAggregator {
/** #3. */
private static final int _3 = 3;
/** Handle svg dimension: 0 0 20 20. */
static final Pattern SIZE = Pattern.compile("(\\d+(\\.\\d+)?)(\\w+)");
/** The logging system. */
private final Logger log = LoggerFactory.getLogger(getClass());
/**
* Creates a new {@link SvgSymbol}.
*/
public SvgSymbol() {
set("output", "sprite");
set("id.prefix", "");
set("id.suffix", "");
set("css.prefix", "");
set("css.round", true);
set("svg.xmlns", "http://www.w3.org/2000/svg");
}
@Override
public List<String> fileset() {
return ImmutableList.of(cssPath());
}
@Override
public void run(final Config conf) throws Exception {
String input = get("input");
if (input == null) {
throw new IllegalArgumentException("Required option 'svg-symbol.input' not present");
}
Path basedir = Paths.get(get("basedir").toString());
Path dir = basedir.resolve(input);
Iterator<Path> files = Files.walk(dir).filter(Files::isRegularFile)
.filter(it -> it.toString().endsWith(".svg"))
.sorted()
.iterator();
List<CharSequence> cssout = new ArrayList<>();
Element svg = new Element(Tag.valueOf("svg"), "");
attrs("svg", "output").forEach((n, v) -> svg.attr(n, v.toString()));
while (files.hasNext()) {
Path file = files.next();
log.debug("{}", file);
String id = get("id.prefix") + file.getFileName().toString().replace(".svg", "")
+ get("id.suffix");
Tuple2<Element, Element> rewrite = symbol(file, id);
svg.appendChild(rewrite._2);
cssout.add(css(id, rewrite._1));
}
write(basedir.resolve(svgPath()), ImmutableList.of(svg.outerHtml()));
write(basedir.resolve(cssPath()), cssout);
}
/**
* Read an object path and optionally filter some child paths.
*
* @param path Path to read.
* @param without Properties to filter.
* @return Properties.
*/
private Map<String, Object> attrs(final String path, final String... without) {
Map<String, Object> attrs = new LinkedHashMap<>(get(path));
Arrays.asList(without).forEach(attrs::remove);
return attrs;
}
/**
* Read a svg file and return the svg element (original) and a new symbol element (created from
* original).
*
* @param file Svg file.
* @param id ID to use.
* @return Svg element (original) and a symbol element (converted).
* @throws IOException If something goes wrong.
*/
private Tuple2<Element, Element> symbol(final Path file, final String id) throws IOException {
Element svg = Jsoup.parse(file.toFile(), "UTF-8").select("svg").first();
Element symbol = new Element(Tag.valueOf("symbol"), "")
.attr("id", id)
.attr("viewBox", svg.attr("viewBox"));
new ArrayList<>(svg.childNodes()).forEach(symbol::appendChild);
return Tuple.of(svg, symbol);
}
/**
* Generate a CSS rule, it reads the width and height attributes of the svg element or fallback to
* viewBox attribute.
*
* @param id ID to use.
* @param svg Svg element to convert.
* @return A css rule.
*/
private CharSequence css(final String id, final Element svg) {
Lazy<Tuple2<Tuple2<Number, String>, Tuple2<Number, String>>> viewBox = Lazy.of(() -> {
String vbox = svg.attr("viewBox");
String[] dimension = vbox.split("\\s+");
return Tuple.of(parse(dimension[2]), parse(dimension[_3]));
});
Tuple2<Number, String> w = Optional.ofNullable(Strings.emptyToNull(svg.attr("width")))
.map(this::parse)
.orElseGet(() -> viewBox.get()._1);
Tuple2<Number, String> h = Optional.ofNullable(Strings.emptyToNull(svg.attr("height")))
.map(this::parse)
.orElseGet(() -> viewBox.get()._2);
StringBuilder css = new StringBuilder();
css.append(get("css.prefix").toString()).append(".").append(id)
.append(" {\n width: ").append(w._1).append(w._2).append(";\n")
.append(" height: ").append(h._1).append(h._2).append(";\n}");
return css;
}
/**
* Parse a css size unit value, like 10px or 18.919px and optionally round the value to the
* closest integer.
*
* @param value Value to parse.
* @return A tuple with a number and unit(px, em, etc...)
*/
private Tuple2<Number, String> parse(final String value) {
Matcher matcher = SIZE.matcher(value);
if (matcher.find()) {
String number = matcher.group(1);
String unit = matcher.group(_3);
boolean round = get("css.round");
Number num = Double.parseDouble(number);
return Tuple.of(round ? Math.round(num.doubleValue()) : num, unit);
}
return null;
}
/**
* Write content to file.
*
* @param path Target file.
* @param sequence File content.
* @throws IOException If something goes wrong.
*/
private void write(final Path path, final List<CharSequence> sequence) throws IOException {
log.debug("writing: {}", path.normalize().toAbsolutePath());
path.toFile().getParentFile().mkdirs();
Files.write(path, sequence);
}
/**
* @return Generate a css path from <code>css.output</code> or fallback to <code>output</code>
*/
private String cssPath() {
String css = Optional.<String> ofNullable(get("css.output")).orElse(get("output"));
return css.endsWith(".css") ? css : css + ".css";
}
/**
* @return Generate a css path from <code>svg.output</code> or fallback to <code>output</code>
*/
private String svgPath() {
String svg = Optional.<String> ofNullable(get("svg.output")).orElse(get("output"));
return svg.endsWith(".svg") ? svg : svg + ".svg";
}
}