Node.java
package io.brunoborges.jairosvg.dom;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.GZIPInputStream;
import javax.xml.XMLConstants;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.helpers.DefaultHandler;
import io.brunoborges.jairosvg.css.CssProcessor;
import io.brunoborges.jairosvg.util.Features;
import io.brunoborges.jairosvg.util.Helpers;
import io.brunoborges.jairosvg.util.UrlHelper;
/**
* SVG Node with dict-like properties and children. Port of CairoSVG parser.py
* (Node and Tree classes).
*/
public class Node {
private static final SAXParserFactory SAFE_SAX_FACTORY;
private static final SAXParserFactory UNSAFE_SAX_FACTORY;
static {
SAFE_SAX_FACTORY = SAXParserFactory.newInstance();
SAFE_SAX_FACTORY.setNamespaceAware(true);
try {
SAFE_SAX_FACTORY.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
SAFE_SAX_FACTORY.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
UNSAFE_SAX_FACTORY = SAXParserFactory.newInstance();
UNSAFE_SAX_FACTORY.setNamespaceAware(true);
}
private static final ThreadLocal<SAXParser> SAFE_PARSER = ThreadLocal.withInitial(() -> {
try {
return SAFE_SAX_FACTORY.newSAXParser();
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
});
private static final ThreadLocal<SAXParser> UNSAFE_PARSER = ThreadLocal.withInitial(() -> {
try {
return UNSAFE_SAX_FACTORY.newSAXParser();
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
});
private static final Set<String> NOT_INHERITED_ATTRIBUTES = Set.of("clip", "clip-path", "display", "filter",
"height", "id", "mask", "opacity", "overflow", "rotate", "stop-color", "stop-opacity", "style", "transform",
"transform-origin", "viewBox", "width", "x", "y", "dx", "dy", "{http://www.w3.org/1999/xlink}href", "href");
private static final Set<String> COLOR_ATTRIBUTES = Set.of("fill", "flood-color", "lighting-color", "stop-color",
"stroke");
public String tag;
public String text;
public String url;
public Node parent;
public boolean root = false;
public List<Node> children;
public double imageWidth;
public double imageHeight;
public List<Object> vertices;
public boolean unsafe = false;
public UrlHelper.UrlFetcher urlFetcher;
private final Map<String, String> attributes;
private Set<String> elementAttrKeys;
/**
* Lightweight constructor for programmatic node creation (e.g. SVG font glyph
* parsing).
*/
Node() {
this.attributes = new LinkedHashMap<>();
this.children = new ArrayList<>();
}
/**
* Create a raw Node from SAX-parsed data. Inherits attributes from parent but
* does NOT apply CSS. Call {@link #applyCss} after the full tree is built.
*/
Node(String tag, String text, Map<String, String> elementAttrs, Node parent, UrlHelper.UrlFetcher urlFetcher,
boolean unsafe) {
int parentAttrCount = parent != null ? parent.attributes.size() : 0;
int elementAttrCount = elementAttrs != null ? elementAttrs.size() : 0;
this.attributes = LinkedHashMap.newLinkedHashMap(parentAttrCount + elementAttrCount + 8);
this.tag = tag;
this.text = text;
this.urlFetcher = urlFetcher;
this.unsafe = unsafe;
this.children = new ArrayList<>();
// Remember element's own attribute keys for re-inheritance in applyCss
this.elementAttrKeys = elementAttrs != null ? Set.copyOf(elementAttrs.keySet()) : Set.of();
// Inherit from parent
if (parent != null) {
this.parent = parent;
this.url = parent.url;
for (var entry : parent.attributes.entrySet()) {
if (!NOT_INHERITED_ATTRIBUTES.contains(entry.getKey())) {
this.attributes.put(entry.getKey(), entry.getValue());
}
}
}
// Copy element attributes (overrides inherited)
if (elementAttrs != null) {
this.attributes.putAll(elementAttrs);
}
}
/**
* Apply CSS rules, inline styles, custom properties, currentColor, and inherit
* resolution. Must be called after the full tree is built so that sibling-based
* CSS selectors (:first-child, :last-child, :nth-child) work correctly.
*/
void applyCss(List<CssProcessor.StyleRule> styleRules) {
// Re-inherit from parent to pick up CSS-applied attributes (e.g. custom
// properties set via stylesheet rules on an ancestor)
if (parent != null) {
for (var entry : parent.attributes.entrySet()) {
String key = entry.getKey();
if (!NOT_INHERITED_ATTRIBUTES.contains(key) && !elementAttrKeys.contains(key)) {
this.attributes.put(key, entry.getValue());
}
}
}
// Apply CSS rules from stylesheets (non-important first, important after inline
// styles)
CssProcessor.MatchResult matchResult = null;
if (styleRules != null && !styleRules.isEmpty()) {
matchResult = CssProcessor.getAllMatchingDeclarations(this, styleRules);
for (var decl : matchResult.normal()) {
this.attributes.put(decl.name(), decl.value());
}
}
// Apply inline style
String style = this.attributes.get("style");
if (style != null && !style.isEmpty()) {
var parsed = CssProcessor.parseDeclarations(style);
for (var decl : parsed[0]) {
this.attributes.put(decl.name(), decl.value());
}
for (var decl : parsed[1]) {
this.attributes.put(decl.name(), decl.value());
}
}
// Apply important CSS rules (override inline styles)
if (matchResult != null) {
for (var decl : matchResult.important()) {
this.attributes.put(decl.name(), decl.value());
}
}
for (var entry : new ArrayList<>(this.attributes.entrySet())) {
this.attributes.put(entry.getKey(),
CssProcessor.resolveCustomProperties(entry.getValue(), this.attributes));
}
// Replace currentColor (lazy-resolve only if needed)
String currentColorValue = null;
for (String attr : COLOR_ATTRIBUTES) {
if ("currentColor".equals(this.attributes.get(attr))) {
if (currentColorValue == null)
currentColorValue = get("color", "black");
this.attributes.put(attr, currentColorValue);
}
}
// Replace inherit
for (var entry : new ArrayList<>(this.attributes.entrySet())) {
if ("inherit".equals(entry.getValue())) {
if (parent != null && parent.attributes.containsKey(entry.getKey())) {
this.attributes.put(entry.getKey(), parent.get(entry.getKey()));
} else {
this.attributes.remove(entry.getKey());
}
}
}
// Process font shorthand
if (this.attributes.containsKey("font")) {
var fontProps = Helpers.parseFont(this.attributes.get("font"));
for (var entry : fontProps.entrySet()) {
if (!this.attributes.containsKey(entry.getKey()) && !entry.getValue().isEmpty()) {
this.attributes.put(entry.getKey(), entry.getValue());
}
}
}
// Free element attribute keys (no longer needed after CSS application)
this.elementAttrKeys = null;
// Recurse into children
for (Node child : this.children) {
child.applyCss(styleRules);
}
}
public String get(String key) {
return attributes.get(key);
}
public String get(String key, String defaultValue) {
return attributes.getOrDefault(key, defaultValue);
}
public void set(String key, String value) {
attributes.put(key, value);
}
public void set(String key, double value) {
attributes.put(key, String.valueOf(value));
}
public boolean has(String key) {
return attributes.containsKey(key);
}
public void remove(String key) {
attributes.remove(key);
}
public Set<Map.Entry<String, String>> entries() {
return attributes.entrySet();
}
/** Get href, checking both xlink:href and href. */
public String getHref() {
String href = get("{http://www.w3.org/1999/xlink}href");
if (href == null)
href = get("xlink:href");
if (href == null)
href = get("href");
return href;
}
/** Fetch URL content. */
public byte[] fetchUrl(UrlHelper.ParsedUrl parsedUrl, String resourceType) throws IOException {
return UrlHelper.readUrl(parsedUrl, urlFetcher, resourceType);
}
// ---------- Tree (static factory) ----------
/** Parse an SVG from bytes, file, or URL into a Node tree. */
public static Node parseTree(byte[] bytestring, String url, UrlHelper.UrlFetcher urlFetcher, boolean unsafe)
throws Exception {
if (bytestring == null && url != null) {
UrlHelper.ParsedUrl parsed = UrlHelper.parseUrl(url);
bytestring = UrlHelper.readUrl(parsed, urlFetcher != null ? urlFetcher : UrlHelper::fetch, "image/svg+xml");
}
if (bytestring == null) {
throw new IllegalArgumentException("No input. Provide bytestring or url.");
}
// Handle gzip
if (bytestring.length > 2 && bytestring[0] == (byte) 0x1f && bytestring[1] == (byte) 0x8b) {
bytestring = new GZIPInputStream(new ByteArrayInputStream(bytestring)).readAllBytes();
}
UrlHelper.UrlFetcher fetcher = urlFetcher;
if (fetcher == null) {
fetcher = unsafe ? UrlHelper::fetch : UrlHelper::safeFetch;
}
// Parse SVG via SAX directly into Node tree
SaxTreeBuilder handler = new SaxTreeBuilder(fetcher, unsafe);
SAXParser parser = unsafe ? UNSAFE_PARSER.get() : SAFE_PARSER.get();
try {
parser.parse(new InputSource(new ByteArrayInputStream(bytestring)), handler);
} finally {
parser.reset();
}
Node tree = handler.getRoot();
if (tree == null) {
throw new IllegalArgumentException("Empty SVG document.");
}
tree.url = url;
tree.root = true;
// Parse CSS stylesheets from <?xml-stylesheet?> processing instructions
List<CssProcessor.StyleRule> styleRules = new ArrayList<>();
if (unsafe && !handler.getStylesheetPIs().isEmpty()) {
styleRules.addAll(CssProcessor.parseExternalStylesheets(handler.getStylesheetPIs(), fetcher, url));
}
// Parse CSS stylesheets from <style> elements
styleRules.addAll(CssProcessor.parseStylesheets(tree));
// Apply CSS to the full tree
tree.applyCss(styleRules);
return tree;
}
public static Node parseTree(byte[] bytestring, String url, boolean unsafe) throws Exception {
return parseTree(bytestring, url, null, unsafe);
}
public static Node parseTree(byte[] bytestring) throws Exception {
return parseTree(bytestring, null, null, false);
}
// ---------- SAX Handler ----------
/** SAX ContentHandler that builds a Node tree directly from SAX events. */
private static class SaxTreeBuilder extends DefaultHandler {
private final UrlHelper.UrlFetcher urlFetcher;
private final boolean unsafe;
private Node root;
private final Deque<Node> stack = new ArrayDeque<>();
private final Deque<StringBuilder> textStack = new ArrayDeque<>();
private final List<String> stylesheetPIs = new ArrayList<>();
// Track skipping depth for elements that fail feature matching
private int skipDepth = 0;
SaxTreeBuilder(UrlHelper.UrlFetcher urlFetcher, boolean unsafe) {
this.urlFetcher = urlFetcher;
this.unsafe = unsafe;
}
Node getRoot() {
return root;
}
List<String> getStylesheetPIs() {
return stylesheetPIs;
}
@Override
public void processingInstruction(String target, String data) {
if ("xml-stylesheet".equals(target)) {
stylesheetPIs.add(data);
}
}
@Override
public void startElement(String uri, String localName, String qName, Attributes saxAttrs) {
if (skipDepth > 0) {
skipDepth++;
return;
}
// Check features using raw SAX attributes
String reqFeatures = saxAttrs.getValue("requiredFeatures");
String reqExtensions = saxAttrs.getValue("requiredExtensions");
String sysLanguage = saxAttrs.getValue("systemLanguage");
if (!Features.matchFeatures(reqFeatures, reqExtensions, sysLanguage)) {
skipDepth = 1;
return;
}
// Determine tag name (strip SVG namespace, prefix others)
String tag;
if (uri == null || "http://www.w3.org/2000/svg".equals(uri) || uri.isEmpty()) {
tag = localName != null && !localName.isEmpty() ? localName : qName;
} else {
String name = localName != null && !localName.isEmpty() ? localName : qName;
tag = "{" + uri + "}" + name;
}
// Extract attributes (skip xmlns declarations)
Map<String, String> attrs = LinkedHashMap.newLinkedHashMap(saxAttrs.getLength());
for (int i = 0; i < saxAttrs.getLength(); i++) {
String name = saxAttrs.getQName(i);
if (name.startsWith("xmlns"))
continue;
attrs.put(name, saxAttrs.getValue(i));
}
Node parent = stack.isEmpty() ? null : stack.peek();
Node node = new Node(tag, null, attrs, parent, urlFetcher, unsafe);
if (parent != null) {
// For <switch> elements, only add the first matching child
if (!"switch".equals(parent.tag) || parent.children.isEmpty()) {
parent.children.add(node);
} else {
// Skip remaining children of <switch> after first match
skipDepth = 1;
return;
}
}
if (root == null) {
root = node;
}
stack.push(node);
textStack.push(new StringBuilder());
}
@Override
public void characters(char[] ch, int start, int length) {
if (skipDepth > 0 || textStack.isEmpty())
return;
textStack.peek().append(ch, start, length);
}
@Override
public void endElement(String uri, String localName, String qName) {
if (skipDepth > 0) {
skipDepth--;
return;
}
if (stack.isEmpty())
return;
Node node = stack.pop();
StringBuilder textBuf = textStack.pop();
// Set direct text content (only text before first child element)
String text = textBuf.toString();
node.text = text.isEmpty() ? null : text;
}
}
}