` nested
+inside another is rendered in place rather than a second time on its own.
+
## Sanitization
The markup is sanitized server-side (`Utils.sanitizeSvg`) to a **static SVG
diff --git a/src/main/java/com/knowledgepixels/nanodash/OntoSvg.java b/src/main/java/com/knowledgepixels/nanodash/OntoSvg.java
new file mode 100644
index 00000000..d50bf079
--- /dev/null
+++ b/src/main/java/com/knowledgepixels/nanodash/OntoSvg.java
@@ -0,0 +1,310 @@
+package com.knowledgepixels.nanodash;
+
+import org.eclipse.rdf4j.model.IRI;
+import org.eclipse.rdf4j.model.Model;
+import org.eclipse.rdf4j.model.Resource;
+import org.eclipse.rdf4j.model.Statement;
+import org.eclipse.rdf4j.model.Value;
+import org.eclipse.rdf4j.model.vocabulary.RDF;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+
+import static java.util.Map.entry;
+
+/**
+ * Serializes an RDF description of an SVG image, as produced by a SPARQL CONSTRUCT
+ * view query, into SVG markup (issue #592).
+ *
+ * The RDF follows the OntoSVG
+ * model: an element is a resource whose {@code rdf:type} carries an {@code xml:tag}
+ * name in the SVG vocabulary, its attributes are the vocabulary properties holding
+ * literals, its children are the numbered container-membership properties
+ * ({@code rdf:_1}, {@code rdf:_2}, ...), and character data is a node whose class has
+ * no tag, carrying its string in {@code xml:fragment}.
+ *
+ *
The markup produced here is not trusted: it is handed to
+ * {@link Utils#sanitizeSvg(String)} before rendering, exactly like the markup a
+ * non-CONSTRUCT SVG view returns in its {@code svg} column.
+ */
+public class OntoSvg {
+
+ /** Namespace of the OntoSVG element and attribute vocabulary. */
+ public static final String SVG_NAMESPACE = "http://www.w3.org/SVG/model/def/";
+
+ /** Namespace of the OntoSVG XML vocabulary, which carries {@code xmlns} and text fragments. */
+ public static final String XML_NAMESPACE = "http://www.w3.org/XML/model/def/";
+
+ /** Namespace of the OntoSVG XLink vocabulary, which carries SVG 1.1 links. */
+ public static final String XLINK_NAMESPACE = "https://www.w3.org/1999/xlink/model/def/";
+
+ private static final String FRAGMENT_PROPERTY = XML_NAMESPACE + "fragment";
+ private static final String XMLNS_PROPERTY = XML_NAMESPACE + "xmlns";
+ private static final String XLINK_HREF_PROPERTY = XLINK_NAMESPACE + "href";
+ private static final String MEMBERSHIP_PREFIX = RDF.NAMESPACE + "_";
+ private static final String ROOT_CLASS = SVG_NAMESPACE + "Svg";
+
+ // Guards against a graph whose membership properties form a cycle, which would
+ // otherwise recurse until the stack gives out. A figure nested this deeply is
+ // past anything the sanitized subset can express.
+ private static final int MAX_DEPTH = 64;
+
+ // The element name for each OntoSVG class, taken from the xml:tag values in the
+ // vocabulary's "svg - core" specification. Not derivable from the class name:
+ // svg:TextElement is (svg:Text is character data, not an element), and
+ // the font and colour-profile classes hyphenate.
+ private static final Map TAG_NAMES = Map.ofEntries(
+ entry("A", "a"),
+ entry("AltGlyph", "altGlyph"),
+ entry("AltGlyphDef", "altGlyphDef"),
+ entry("AltGlyphItem", "altGlyphItem"),
+ entry("Animate", "animate"),
+ entry("AnimateColor", "animateColor"),
+ entry("AnimateMotion", "animateMotion"),
+ entry("AnimateTransform", "animateTransform"),
+ entry("Circle", "circle"),
+ entry("ClipPath", "clipPath"),
+ entry("ColorProfile", "color-profile"),
+ entry("Cursor", "cursor"),
+ entry("Defs", "defs"),
+ entry("Desc", "desc"),
+ entry("Ellipse", "ellipse"),
+ entry("FeBlend", "feBlend"),
+ entry("FeColorMatrix", "feColorMatrix"),
+ entry("FeComponentTransfer", "feComponentTransfer"),
+ entry("FeComposite", "feComposite"),
+ entry("FeConvolveMatrix", "feConvolveMatrix"),
+ entry("FeDiffuseLighting", "feDiffuseLighting"),
+ entry("FeDisplacementMap", "feDisplacementMap"),
+ entry("FeDistantLight", "feDistantLight"),
+ entry("FeFlood", "feFlood"),
+ entry("FeFuncA", "feFuncA"),
+ entry("FeFuncB", "feFuncB"),
+ entry("FeFuncG", "feFuncG"),
+ entry("FeFuncR", "feFuncR"),
+ entry("FeGaussianBlur", "feGaussianBlur"),
+ entry("FeImage", "feImage"),
+ entry("FeMerge", "feMerge"),
+ entry("FeMergeNode", "feMergeNode"),
+ entry("FeMorphology", "feMorphology"),
+ entry("FeOffset", "feOffset"),
+ entry("FePointLight", "fePointLight"),
+ entry("FeSpecularLighting", "feSpecularLighting"),
+ entry("FeSpotLight", "feSpotLight"),
+ entry("FeTile", "feTile"),
+ entry("FeTurbulence", "feTurbulence"),
+ entry("Filter", "filter"),
+ entry("Font", "font"),
+ entry("FontFace", "font-face"),
+ entry("FontFaceFormat", "font-face-format"),
+ entry("FontFaceName", "font-face-name"),
+ entry("FontFaceSrc", "font-face-src"),
+ entry("FontFaceUri", "font-face-uri"),
+ entry("ForeignObject", "foreignObject"),
+ entry("G", "g"),
+ entry("Glyph", "glyph"),
+ entry("GlyphRef", "glyphRef"),
+ entry("Hkern", "hkern"),
+ entry("Image", "image"),
+ entry("Line", "line"),
+ entry("LinearGradient", "linearGradient"),
+ entry("Marker", "marker"),
+ entry("Mask", "mask"),
+ entry("Metadata", "metadata"),
+ entry("MissingGlyph", "missing-glyph"),
+ entry("Mpath", "mpath"),
+ entry("Path", "path"),
+ entry("Pattern", "pattern"),
+ entry("Polygon", "polygon"),
+ entry("Polyline", "polyline"),
+ entry("RadialGradient", "radialGradient"),
+ entry("Rect", "rect"),
+ entry("Script", "script"),
+ entry("Set", "set"),
+ entry("Stop", "stop"),
+ entry("Style", "style"),
+ entry("Svg", "svg"),
+ entry("Switch", "switch"),
+ entry("Symbol", "symbol"),
+ entry("TextElement", "text"),
+ entry("TextPath", "textPath"),
+ entry("Title", "title"),
+ entry("Tref", "tref"),
+ entry("Tspan", "tspan"),
+ entry("Use", "use"),
+ entry("View", "view"),
+ entry("Vkern", "vkern")
+ );
+
+ private OntoSvg() {
+ }
+
+ /**
+ * Serializes every SVG image described in the given model, outermost images only,
+ * in a stable order.
+ *
+ * @param model the RDF description of zero or more SVG images
+ * @return the SVG markup of each image, one string per image
+ */
+ public static List toSvgMarkup(Model model) {
+ List figures = new ArrayList<>();
+ if (model == null) return figures;
+ for (Resource root : findRoots(model)) {
+ StringBuilder markup = new StringBuilder();
+ appendNode(model, root, markup, 0, new HashSet<>());
+ if (!markup.isEmpty()) figures.add(markup.toString());
+ }
+ return figures;
+ }
+
+ // The outermost svg:Svg nodes: those not contained in another one, so that an
+ // nested inside a figure is rendered once, in its place, rather than a second time as
+ // a figure of its own. Being someone's child is not the test -- the vocabulary wraps a
+ // whole document in an svg:Document holding the doctype and the svg:Svg, and that
+ // wrapper's child is still the figure's root.
+ private static List findRoots(Model model) {
+ List candidates = new ArrayList<>();
+ for (Statement st : model.filter(null, RDF.TYPE, null)) {
+ if (!ROOT_CLASS.equals(st.getObject().stringValue())) continue;
+ if (!candidates.contains(st.getSubject())) candidates.add(st.getSubject());
+ }
+ Set nested = new HashSet<>();
+ for (Resource candidate : candidates) {
+ collectNestedImages(model, candidate, nested, new HashSet<>());
+ }
+ List roots = new ArrayList<>(candidates);
+ roots.removeAll(nested);
+ // A model is an unordered set of statements, so without this a query returning
+ // several figures would render them in a different order on each fetch.
+ roots.sort(Comparator.comparing(Value::stringValue));
+ return roots;
+ }
+
+ private static void collectNestedImages(Model model, Resource node, Set nested, Set visited) {
+ if (!visited.add(node)) return;
+ for (Resource child : childrenOf(model, node)) {
+ if (isImage(model, child)) nested.add(child);
+ collectNestedImages(model, child, nested, visited);
+ }
+ }
+
+ private static boolean isImage(Model model, Resource node) {
+ for (Statement st : model.filter(node, RDF.TYPE, null)) {
+ if (ROOT_CLASS.equals(st.getObject().stringValue())) return true;
+ }
+ return false;
+ }
+
+ private static void appendNode(Model model, Resource node, StringBuilder out, int depth, Set ancestors) {
+ if (depth > MAX_DEPTH || !ancestors.add(node)) return;
+ try {
+ String tag = tagOf(model, node);
+ if (tag == null) {
+ out.append(escape(literalOf(model, node, FRAGMENT_PROPERTY)));
+ return;
+ }
+ out.append('<').append(tag);
+ for (Map.Entry attribute : attributesOf(model, node).entrySet()) {
+ out.append(' ').append(attribute.getKey()).append("=\"").append(escape(attribute.getValue())).append('"');
+ }
+ out.append('>');
+ for (Resource child : childrenOf(model, node)) {
+ appendNode(model, child, out, depth + 1, ancestors);
+ }
+ out.append("").append(tag).append('>');
+ } finally {
+ ancestors.remove(node);
+ }
+ }
+
+ // Null for anything that is not an element -- character data, and any class the
+ // vocabulary gives no tag name.
+ private static String tagOf(Model model, Resource node) {
+ for (Statement st : model.filter(node, RDF.TYPE, null)) {
+ String type = st.getObject().stringValue();
+ if (!type.startsWith(SVG_NAMESPACE)) continue;
+ String tag = TAG_NAMES.get(type.substring(SVG_NAMESPACE.length()));
+ if (tag != null) return tag;
+ }
+ return null;
+ }
+
+ private static Map attributesOf(Model model, Resource node) {
+ // Sorted, because the statements arrive unordered and the same figure should
+ // produce the same markup every time it is rendered.
+ Map attributes = new TreeMap<>();
+ for (Statement st : model.filter(node, null, null)) {
+ if (!(st.getObject() instanceof org.eclipse.rdf4j.model.Literal)) continue;
+ String name = attributeNameOf(st.getPredicate());
+ if (name != null) attributes.put(name, st.getObject().stringValue());
+ }
+ return reorderXmlnsFirst(attributes);
+ }
+
+ // The attribute's name is the local name of its property, per the vocabulary, where
+ // every attribute's xml:key matches it. Qualified with a prefix only when it comes
+ // from another namespace than the element, which for the sanitized subset leaves
+ // only the link: OntoSVG models SVG 1.1, whose xlink:href is written href in SVG 2
+ // -- and href is the spelling that survives sanitization and that browsers follow.
+ private static String attributeNameOf(IRI predicate) {
+ String iri = predicate.stringValue();
+ if (XLINK_HREF_PROPERTY.equals(iri)) return "href";
+ if (XMLNS_PROPERTY.equals(iri)) return "xmlns";
+ if (FRAGMENT_PROPERTY.equals(iri)) return null;
+ if (iri.startsWith(SVG_NAMESPACE)) return iri.substring(SVG_NAMESPACE.length());
+ return null;
+ }
+
+ private static Map reorderXmlnsFirst(Map attributes) {
+ String xmlns = attributes.remove("xmlns");
+ if (xmlns == null) return attributes;
+ Map ordered = new LinkedHashMap<>();
+ ordered.put("xmlns", xmlns);
+ ordered.putAll(attributes);
+ return ordered;
+ }
+
+ private static List childrenOf(Model model, Resource node) {
+ Map byPosition = new TreeMap<>();
+ for (Statement st : model.filter(node, null, null)) {
+ if (!isMembershipProperty(st.getPredicate())) continue;
+ if (!(st.getObject() instanceof Resource child)) continue;
+ Integer position = positionOf(st.getPredicate());
+ // Numerically, so that rdf:_10 follows rdf:_9 instead of rdf:_1.
+ if (position != null) byPosition.put(position, child);
+ }
+ return new ArrayList<>(byPosition.values());
+ }
+
+ private static boolean isMembershipProperty(IRI predicate) {
+ return predicate.stringValue().startsWith(MEMBERSHIP_PREFIX);
+ }
+
+ private static Integer positionOf(IRI predicate) {
+ try {
+ return Integer.valueOf(predicate.stringValue().substring(MEMBERSHIP_PREFIX.length()));
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ }
+
+ private static String literalOf(Model model, Resource node, String property) {
+ for (Statement st : model.filter(node, null, null)) {
+ if (property.equals(st.getPredicate().stringValue())) return st.getObject().stringValue();
+ }
+ return "";
+ }
+
+ private static String escape(String value) {
+ if (value == null) return "";
+ return value.replace("&", "&").replace("<", "<").replace(">", ">")
+ .replace("\"", """).replace("'", "'");
+ }
+
+}
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultSvg.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultSvg.java
index 4f8a9459..6d6fe19c 100644
--- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultSvg.java
+++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultSvg.java
@@ -27,6 +27,10 @@
* figure per result row, with an optional "title" column as the figure's heading.
* The markup is sanitized to a static-SVG subset before rendering, so the query
* fully controls the visual but cannot inject scripting or styling.
+ *
+ * A CONSTRUCT query describing the figure in the OntoSVG vocabulary reaches this
+ * component the same way: its graph is serialized to markup and handed on as the
+ * "svg" column of one row per figure (issue #592).
*/
public class QueryResultSvg extends QueryResult {
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultSvgBuilder.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultSvgBuilder.java
index 5602b107..a38e21e0 100644
--- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultSvgBuilder.java
+++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultSvgBuilder.java
@@ -1,10 +1,13 @@
package com.knowledgepixels.nanodash.component;
import com.knowledgepixels.nanodash.ApiCache;
+import com.knowledgepixels.nanodash.GrlcQuery;
+import com.knowledgepixels.nanodash.OntoSvg;
import com.knowledgepixels.nanodash.ViewDisplay;
import com.knowledgepixels.nanodash.domain.AbstractResourceWithProfile;
import org.apache.wicket.Component;
import org.apache.wicket.behavior.AttributeAppender;
+import org.eclipse.rdf4j.model.Model;
import org.nanopub.extra.services.ApiResponse;
import org.nanopub.extra.services.QueryRef;
@@ -83,12 +86,49 @@ public QueryResultSvgBuilder refRoot(String refRoot) {
* @return the QueryResultSvg component
*/
public Component build() {
- ApiResponse response = ApiCache.retrieveResponseAsync(queryRef);
- Component comp = ApiResultComponent.create(markupId, queryRef, response, viewDisplay.getTitle(), this::buildSvg);
+ Component comp = isConstructQuery() ? buildFromRdfResult() : buildFromTabularResult();
comp.add(new AttributeAppender("class", " col-" + viewDisplay.getDisplayWidth()));
return comp;
}
+ // A CONSTRUCT view query describes the figure in RDF instead of returning its markup
+ // in an svg column (issue #592). A query that cannot be loaded is left to the tabular
+ // path, which reports the failure the same way it always has.
+ private boolean isConstructQuery() {
+ try {
+ return GrlcQuery.get(queryRef).isConstructQuery();
+ } catch (Exception ex) {
+ return false;
+ }
+ }
+
+ private Component buildFromTabularResult() {
+ ApiResponse response = ApiCache.retrieveResponseAsync(queryRef);
+ return ApiResultComponent.create(markupId, queryRef, response, viewDisplay.getTitle(), this::buildSvg);
+ }
+
+ private Component buildFromRdfResult() {
+ Model model = ApiCache.retrieveRdfModelAsync(queryRef);
+ if (model != null) return buildSvg(markupId, asFigureRows(model));
+ return new RdfResultComponent(markupId, queryRef) {
+ @Override
+ public Component getRdfResultComponent(String id, Model loadedModel) {
+ return buildSvg(id, asFigureRows(loadedModel));
+ }
+ };
+ }
+
+ // The serialized figures are handed on as the svg column the view already renders, so
+ // that headings, actions, sanitization and the empty state stay in one place.
+ private static ApiResponse asFigureRows(Model model) {
+ ApiResponse response = new ApiResponse();
+ response.setHeader(new String[]{"svg"});
+ for (String figure : OntoSvg.toSvgMarkup(model)) {
+ response.add(new String[]{figure});
+ }
+ return response;
+ }
+
private QueryResultSvg buildSvg(String markupId, ApiResponse response) {
QueryResultSvg resultSvg = new QueryResultSvg(markupId, queryRef, response, viewDisplay);
resultSvg.setPageResource(pageResource);
diff --git a/src/main/java/com/knowledgepixels/nanodash/vocabulary/KPXL_TERMS.java b/src/main/java/com/knowledgepixels/nanodash/vocabulary/KPXL_TERMS.java
index 6cd2ce39..d34ad267 100644
--- a/src/main/java/com/knowledgepixels/nanodash/vocabulary/KPXL_TERMS.java
+++ b/src/main/java/com/knowledgepixels/nanodash/vocabulary/KPXL_TERMS.java
@@ -29,6 +29,10 @@ public class KPXL_TERMS {
* optional {@code title} column as its heading). Unlike the other display types,
* the query computes the visual itself — e.g. a diagram laid out in SPARQL from
* the underlying data.
+ *
+ *
A CONSTRUCT query may instead describe the figure as RDF in the OntoSVG
+ * vocabulary, which is serialized to markup before the same sanitization and
+ * rendering (issue #592); see {@link com.knowledgepixels.nanodash.OntoSvg}.
*/
public static final IRI SVG_VIEW = VocabUtils.createIRI(NAMESPACE, "SvgView");
diff --git a/src/test/java/com/knowledgepixels/nanodash/OntoSvgTest.java b/src/test/java/com/knowledgepixels/nanodash/OntoSvgTest.java
new file mode 100644
index 00000000..5afb0402
--- /dev/null
+++ b/src/test/java/com/knowledgepixels/nanodash/OntoSvgTest.java
@@ -0,0 +1,209 @@
+package com.knowledgepixels.nanodash;
+
+import org.eclipse.rdf4j.model.Model;
+import org.eclipse.rdf4j.rio.RDFFormat;
+import org.eclipse.rdf4j.rio.Rio;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Serializing the RDF description of an SVG image, as a CONSTRUCT view query returns it,
+ * into the markup an SVG view renders (issue #592).
+ */
+class OntoSvgTest {
+
+ private static final String PREFIXES = """
+ prefix doc:
+ prefix rdf:
+ prefix svg:
+ prefix xml:
+ prefix xlink:
+ """;
+
+ private static Model parse(String turtleBody) {
+ try {
+ return Rio.parse(new StringReader(PREFIXES + turtleBody), "", RDFFormat.TURTLE);
+ } catch (IOException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private static String serializeOne(String turtleBody) {
+ List figures = OntoSvg.toSvgMarkup(parse(turtleBody));
+ assertEquals(1, figures.size(), "expected exactly one figure, got " + figures);
+ return figures.getFirst();
+ }
+
+ // The smiley from the OntoSVG repository, whose published SVG rendering is a 200x200
+ // yellow face: head, two eyes, and a mouth path, in that order.
+ private static final String SMILEY = """
+ doc:1 a svg:Document ;
+ rdf:_1 doc:1.1 ;
+ rdf:_2 doc:1.2 .
+ doc:1.1 rdf:type xml:DocumentType ;
+ xml:documentTypeName 'svg' .
+ doc:1.2 a svg:Svg ;
+ rdf:_1 doc:10.0 ; rdf:_2 doc:10.1 ; rdf:_3 doc:10.2 ; rdf:_4 doc:10.3 ;
+ rdf:_5 doc:10.4 ; rdf:_6 doc:10.5 ; rdf:_7 doc:10.6 ; rdf:_8 doc:10.7 ;
+ rdf:_9 doc:10.8 ; rdf:_10 doc:10.9 ; rdf:_11 doc:10.10 ; rdf:_12 doc:10.11 ;
+ rdf:_13 doc:10.12 ; rdf:_14 doc:10.13 ; rdf:_15 doc:10.14 ;
+ xml:xmlns "http://www.w3.org/2000/svg" ;
+ svg:height "200" ;
+ svg:width "200" .
+ doc:10.0 a svg:Text ; xml:fragment "" .
+ doc:10.1 a svg:Text ; xml:fragment "" .
+ doc:10.2 a svg:Text ; xml:fragment "" .
+ doc:10.3 a svg:Circle ;
+ svg:cx "100" ; svg:cy "100" ; svg:fill "yellow" ; svg:r "90" ;
+ svg:stroke "black" ; svg:stroke-width "2" .
+ doc:10.4 a svg:Text ; xml:fragment "" .
+ doc:10.5 a svg:Text ; xml:fragment "" .
+ doc:10.6 a svg:Text ; xml:fragment "" .
+ doc:10.7 a svg:Circle ;
+ svg:cx "70" ; svg:cy "70" ; svg:fill "black" ; svg:r "10" .
+ doc:10.8 a svg:Text ; xml:fragment "" .
+ doc:10.9 a svg:Circle ;
+ svg:cx "130" ; svg:cy "70" ; svg:fill "black" ; svg:r "10" .
+ doc:10.10 a svg:Text ; xml:fragment "" .
+ doc:10.11 a svg:Text ; xml:fragment "" .
+ doc:10.12 a svg:Text ; xml:fragment "" .
+ doc:10.13 a svg:Path ;
+ svg:d "M 60 120 Q 100 150 140 120" ; svg:fill "none" ;
+ svg:stroke "black" ; svg:stroke-width "3" .
+ doc:10.14 a svg:Text ; xml:fragment "" .
+ """;
+
+ @Test
+ void serializesTheReferenceSmiley() {
+ assertEquals(""
+ + " "
+ + " "
+ + " "
+ + " "
+ + " ",
+ serializeOne(SMILEY));
+ }
+
+ @Test
+ void serializedSmileySurvivesSanitizationUnchanged() {
+ String markup = serializeOne(SMILEY);
+ String sanitized = Utils.sanitizeSvg(markup);
+ assertTrue(sanitized.contains("first second tenth
", markup);
+ }
+
+ @Test
+ void textElementIsTheElementAndTextIsCharacterData() {
+ String markup = serializeOne("""
+ doc:s a svg:Svg ; rdf:_1 doc:label .
+ doc:label a svg:TextElement ;
+ svg:x "10" ; svg:y "20" ;
+ rdf:_1 doc:content .
+ doc:content a svg:Text ; xml:fragment "Hello" .
+ """);
+ assertEquals("Hello ", markup);
+ }
+
+ @Test
+ void hyphenatedTagNamesComeFromTheVocabulary() {
+ String markup = serializeOne("""
+ doc:s a svg:Svg ; rdf:_1 doc:ff .
+ doc:ff a svg:FontFace .
+ """);
+ assertEquals(" ", markup);
+ }
+
+ @Test
+ void xlinkHrefBecomesTheHrefThatSurvivesSanitization() {
+ String markup = serializeOne("""
+ doc:s a svg:Svg ; rdf:_1 doc:link .
+ doc:link a svg:A ;
+ xlink:href "https://example.org/thing" ;
+ rdf:_1 doc:t .
+ doc:t a svg:Text ; xml:fragment "label" .
+ """);
+ assertEquals("label ", markup);
+ assertTrue(Utils.sanitizeSvg(markup).contains("href=\"https://example.org/thing\""));
+ }
+
+ @Test
+ void dataDerivedTextAndAttributesAreEscaped() {
+ String markup = serializeOne("""
+ doc:s a svg:Svg ; rdf:_1 doc:label .
+ doc:label a svg:TextElement ;
+ svg:font-family "a\\"b" ;
+ rdf:_1 doc:t .
+ doc:t a svg:Text ; xml:fragment " & more" .
+ """);
+ assertFalse(markup.contains("