From 4328f509e1eef8e3e8c242fe573b3af7f536b5a5 Mon Sep 17 00:00:00 2001 From: Ashley Caselli Date: Mon, 14 Sep 2026 11:23:00 +0200 Subject: [PATCH] feat(views): render SVG views from CONSTRUCT queries in OntoSVG An SVG view's query had to return ready-made markup in an svg column, which means building the figure as a string in SPARQL: group_concat, whose ordering is not dependable, and hand-escaping every data-derived label. A view query may now instead be a CONSTRUCT describing the image as RDF in the OntoSVG vocabulary. The graph is serialized to markup and handed on as the svg column the view already renders, so headings, actions, sanitization and the empty state stay in one place, and the display type is unchanged. The element names come from the vocabulary rather than from the class names, which do not always agree: svg:TextElement is , while svg:Text is character data, and the font and colour-profile classes hyphenate. Children are read from rdf:_1, rdf:_2, ... in numeric order, so rdf:_10 follows rdf:_9 and the order does not depend on how the graph came back. Text and attribute values are XML-escaped on the way out, and the markup still goes through Utils.sanitizeSvg, which remains the only thing deciding what may render. OntoSVG models SVG 1.1, whose links are xlink:href. Those are written as href, the SVG 2 spelling, which is both what the sanitizer keeps and what browsers follow; emitting xlink:href would render links that silently do nothing. Closes #592 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NnGpEQkFqCaf4AEQwwhFFn --- docs/svg-views.md | 73 ++++- .../com/knowledgepixels/nanodash/OntoSvg.java | 310 ++++++++++++++++++ .../nanodash/component/QueryResultSvg.java | 4 + .../component/QueryResultSvgBuilder.java | 44 ++- .../nanodash/vocabulary/KPXL_TERMS.java | 4 + .../knowledgepixels/nanodash/OntoSvgTest.java | 209 ++++++++++++ 6 files changed, 641 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/knowledgepixels/nanodash/OntoSvg.java create mode 100644 src/test/java/com/knowledgepixels/nanodash/OntoSvgTest.java diff --git a/docs/svg-views.md b/docs/svg-views.md index 7acaa6a6..4aa06c66 100644 --- a/docs/svg-views.md +++ b/docs/svg-views.md @@ -7,6 +7,10 @@ computes the *visual itself* — e.g. a diagram laid out in SPARQL from the unde data — and Nanodash only sanitizes and embeds it. The query API needs no changes for this: the SVG travels as an ordinary result-cell string. +The query can supply that figure in either of two ways: as **markup**, in an `svg` +result column, or as **RDF**, from a CONSTRUCT query describing the image in the +OntoSVG vocabulary. Both end up in the same sanitizer and the same container. + ## View declaration Exactly like any other view, with `gen:SvgView` as the display type: @@ -26,7 +30,7 @@ and view actions all work as for the other view types. Older Nanodash instances without SVG-view support skip the view silently (unknown display type), and the document export renders a "(view type not supported in document export)" note. -## Query contract +## Query contract (markup) - **`svg`** — the complete SVG markup (`...`), one rendered figure per result row. Typically such a query returns a single row. @@ -36,6 +40,73 @@ document export renders a "(view type not supported in document export)" note. Entry actions and their query mappings work per row as usual. +## RDF figures (CONSTRUCT queries) + +A view query may instead be a SPARQL **CONSTRUCT** that describes the figure as RDF, in +the [OntoSVG](https://github.com/floresbakker/OntoSVG) vocabulary. Nanodash serializes +that graph to SVG markup and then sanitizes and renders it exactly as above, so nothing +else about the view declaration changes — the display type stays `gen:SvgView`. + +This is the better option whenever the figure is assembled from many parts. Building +markup as a string means `group_concat`, whose ordering is not dependable (see the +authoring notes below); describing the elements as RDF lets the numbered child +properties carry the order instead, and leaves the escaping to Nanodash. + +```sparql +CONSTRUCT { + ?figure a svg:Svg ; + xml:xmlns "http://www.w3.org/2000/svg" ; + svg:width "200" ; svg:height "200" ; + rdf:_1 ?box . + ?box a svg:Rect ; + svg:x "10" ; svg:y "10" ; svg:width "180" ; svg:height "40" ; + svg:fill "#eef" ; + rdf:_1 ?caption . + ?caption a svg:TextElement ; + svg:x "20" ; svg:y "35" ; + rdf:_1 ?captionText . + ?captionText a svg:Text ; xml:fragment ?label . +} WHERE { ... } +``` + +The vocabulary namespaces are `svg: `, +`xml: ` and +`xlink: `. + +### How the graph is read + +- **Elements** are resources whose `rdf:type` is an OntoSVG class — `svg:Rect`, + `svg:Circle`, `svg:G`. The element name comes from the vocabulary, not from the class + name: most are simply lower-cased, but `svg:ColorProfile` is `color-profile`, the + `svg:FontFace*` classes hyphenate, and see the warning about `svg:Text` below. +- **Attributes** are the `svg:` properties holding literals: `svg:cx "100"`, + `svg:stroke-width "3"`. The attribute's name is the property's local name. +- **Children** are the numbered properties `rdf:_1`, `rdf:_2`, … , read in *numeric* + order, so `rdf:_10` follows `rdf:_9`. Their order in the graph is irrelevant. +- **Text** is a node with no element class carrying its string in `xml:fragment`, as + `svg:Text` does. It is XML-escaped on the way out, so query-derived labels need no + escaping of their own. +- `xml:xmlns` becomes the `xmlns` attribute. +- `xlink:href` is written as `href`. OntoSVG models SVG 1.1, where links are + `xlink:href`; `href` is the SVG 2 spelling, and the one that both survives + sanitization and is followed by browsers. + +> **`svg:Text` is not ``.** In OntoSVG, `svg:Text` is an XML *text node* — the +> characters themselves — and the `` element is **`svg:TextElement`**. A label is +> therefore an `svg:TextElement` with an `svg:Text` child holding the string. Typing the +> label node `svg:Text` directly renders nothing. + +Anything the vocabulary does not define — an unknown class, a property from another +namespace — is left out rather than guessed at. + +### What a CONSTRUCT view gives up + +The tabular form carries one figure per row and reads `title` and `np` from the row's +other columns. A CONSTRUCT result has no columns, so a figure rendered this way has no +per-figure heading and no source link; the view's own title still shows. Each outermost +`svg:Svg` in the graph becomes one figure, in a stable order, and an `` 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("'); + } 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("firstsecondtenth", 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("