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("