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.tools;
017
018import java.io.IOException;
019import java.nio.file.Path;
020import java.util.Locale;
021import java.util.Objects;
022import java.util.function.Function;
023import java.util.stream.Stream;
024
025import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
026import org.apache.batik.anim.dom.SVGOMSVGElement;
027import org.apache.batik.util.XMLResourceDescriptor;
028import org.w3c.dom.Document;
029import org.w3c.dom.Node;
030import org.w3c.dom.NodeList;
031import org.w3c.dom.svg.SVGAnimatedRect;
032import org.w3c.dom.svg.SVGRect;
033
034public class SvgValidator {
035
036    private final SAXSVGDocumentFactory factory =
037            new SAXSVGDocumentFactory(XMLResourceDescriptor.getXMLParserClassName());
038
039    private final Boolean legacy;
040
041    public SvgValidator(final Boolean legacy) {
042        this.legacy = legacy;
043    }
044
045    public Stream<String> validate(final Path path) {
046        final String prefix = "[" + path.getFileName() + "] ";
047
048        final SVGOMSVGElement icon;
049        try {
050            icon = loadSvg(path);
051        } catch (final IllegalStateException e) {
052            return Stream.of(prefix + "Invalid SVG: " + e.getMessage());
053        }
054        return Stream
055                .<Function<SVGOMSVGElement, String>> of(this::noEmbedStyle, this::pathsAreClosed, this::noDisplayNone,
056                        this::viewportSize)
057                .map(fn -> fn.apply(icon))
058                .filter(Objects::nonNull)
059                .map(error -> prefix + error);
060    }
061
062    private SVGOMSVGElement loadSvg(final Path path) {
063        try {
064            final Document document = factory.createDocument(path.toUri().toASCIIString());
065            return SVGOMSVGElement.class.cast(document.getDocumentElement());
066        } catch (final IOException e) {
067            throw new IllegalStateException(e);
068        }
069    }
070
071    private String viewportSize(final SVGOMSVGElement icon) {
072        final SVGAnimatedRect viewBox = icon.getViewBox();
073        final int vb16 = 16;
074        final int vb40 = 40;
075        if (viewBox == null) {
076            return String.format("No viewBox, need one with '0 0 %d %d' (family) or '0 0 %d %d' (connector).", vb16,
077                    vb16, vb40, vb40);
078        }
079        final SVGRect baseVal = viewBox.getBaseVal();
080        if (baseVal.getX() != 0 || baseVal.getY() != 0 || (baseVal.getHeight() != vb16 && baseVal.getHeight() != vb40)
081                || (baseVal.getWidth() != vb16 && baseVal.getWidth() != vb40)) {
082            return String.format("viewBox must be '0 0 %d %d' (family) or '0 0 %d %d' (connector) found '%d %d %d %d'",
083                    vb16, vb16, vb40, vb40, (int) baseVal.getX(), (int) baseVal.getY(), (int) baseVal.getWidth(),
084                    (int) baseVal.getHeight());
085        }
086        return null;
087    }
088
089    private String pathsAreClosed(final SVGOMSVGElement icon) {
090        return browseDom(icon, node -> {
091            if ("path".equals(node.getNodeName())) {
092                final Node d = node.getAttributes() == null ? null : node.getAttributes().getNamedItem("d");
093                if (d == null || d.getNodeValue() == null) {
094                    return "Missing 'd' in a path";
095                }
096                if (!d.getNodeValue().toLowerCase(Locale.ROOT).endsWith("z")) {
097                    return "All path must be closed so end with 'z', found value: '" + d.getNodeValue() + '\'';
098                }
099            }
100            return null;
101        });
102    }
103
104    private String noEmbedStyle(final SVGOMSVGElement icon) {
105        return browseDom(icon, node -> node.getNodeName().equals("style") ? "Forbidden <style> in icon" : null);
106    }
107
108    private String noDisplayNone(final SVGOMSVGElement icon) {
109        return browseDom(icon, node -> {
110            final Node display = node.getAttributes() == null ? null : node.getAttributes().getNamedItem("display");
111            if (display != null && display.getNodeValue() != null) {
112                return display.getNodeValue().replaceAll(" +", "").equalsIgnoreCase("none")
113                        ? "'display:none' is forbidden in SVG icons"
114                        : null;
115            }
116            return null;
117        });
118    }
119
120    private String browseDom(final Node element, final Function<Node, String> errorFactory) {
121        {
122            final String error = errorFactory.apply(element);
123            if (error != null) {
124                return error;
125            }
126        }
127        final NodeList childNodes = element.getChildNodes();
128        if (childNodes.getLength() > 0) {
129            for (int i = 0; i < childNodes.getLength(); i++) {
130                final Node node = childNodes.item(i);
131
132                {
133                    final String error = errorFactory.apply(node);
134                    if (error != null) {
135                        return error;
136                    }
137                }
138
139                final String error = browseDom(node, errorFactory);
140                if (error != null) {
141                    return error;
142                }
143            }
144        }
145        return null;
146    }
147}