1   package eu.fbk.dkm.pikes.rdf.util;
2   
3   import com.google.common.base.Charsets;
4   import com.google.common.base.Joiner;
5   import com.google.common.base.Strings;
6   import com.google.common.base.Throwables;
7   import com.google.common.collect.*;
8   import com.google.common.html.HtmlEscapers;
9   import eu.fbk.rdfpro.util.*;
10  import org.eclipse.rdf4j.model.*;
11  import org.eclipse.rdf4j.model.vocabulary.RDF;
12  import org.eclipse.rdf4j.model.vocabulary.XMLSchema;
13  import org.slf4j.Logger;
14  import org.slf4j.LoggerFactory;
15  
16  import javax.annotation.Nullable;
17  import java.io.*;
18  import java.util.Comparator;
19  import java.util.List;
20  import java.util.Map;
21  import java.util.Set;
22  
23  public final class RDFGraphvizRenderer {
24  
25      private static final Logger LOGGER = LoggerFactory.getLogger(RDFGraphvizRenderer.class);
26  
27      private static final String NEWLINE = "
";
28  
29      private final Set<String> nodeNamespaces;
30  
31      private final Set<Resource> nodeTypes;
32  
33      private final Set<IRI> ignoredProperties;
34  
35      private final Set<IRI> collapsedProperties;
36  
37      private final Map<? super Resource, String> colorMap;
38  
39      private final Map<? super Resource, String> styleMap;
40  
41      private final Namespaces namespaces;
42  
43      private final Ordering<? super Value> valueComparator;
44  
45      private final String graphvizCommand;
46  
47      private RDFGraphvizRenderer(final Builder builder) {
48          this.nodeNamespaces = builder.nodeNamespaces == null ? ImmutableSet.of() //
49                  : ImmutableSet.copyOf(builder.nodeNamespaces);
50          this.nodeTypes = builder.nodeTypes == null ? ImmutableSet.of() //
51                  : ImmutableSet.copyOf(builder.nodeTypes);
52          this.ignoredProperties = builder.ignoredProperties == null ? ImmutableSet.of() //
53                  : ImmutableSet.copyOf(builder.ignoredProperties);
54          this.collapsedProperties = builder.collapsedProperties == null ? ImmutableSet.of() //
55                  : ImmutableSet.copyOf(builder.collapsedProperties);
56          this.colorMap = builder.colorMap == null ? null : ImmutableMap.copyOf(builder.colorMap);
57          this.styleMap = builder.styleMap == null ? null : ImmutableMap.copyOf(builder.styleMap);
58          this.namespaces = builder.namespaces == null ? Namespaces.DEFAULT : builder.namespaces;
59          this.valueComparator = builder.valueComparator != null ? Ordering
60                  .from(builder.valueComparator) : Ordering.from(Statements.valueComparator());
61          this.graphvizCommand = builder.graphvizCommand == null ? "neato" : builder.graphvizCommand;
62      }
63  
64      public <T extends Appendable> T emitSVG(final T out, final QuadModel model) throws IOException {
65  
66          Process process = null;
67          File dotFile = null;
68  
69          try {
70              dotFile = File.createTempFile("graphviz-", ".dot");
71              dotFile.deleteOnExit();
72  
73              try (Writer writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(
74                      dotFile), Charsets.UTF_8))) {
75                  emitDot(writer, model);
76              }
77  
78              process = new ProcessBuilder().command(this.graphvizCommand,
79                      dotFile.getAbsolutePath(), "-Tsvg").start();
80  
81              final BufferedReader in = new BufferedReader(new InputStreamReader(
82                      process.getInputStream()));
83              final BufferedReader err = new BufferedReader(new InputStreamReader(
84                      process.getErrorStream()));
85  
86              Environment.getPool().submit(new Runnable() {
87  
88                  @Override
89                  public void run() {
90                      String line;
91                      try {
92                          while ((line = err.readLine()) != null) {
93                              LOGGER.error("[" + RDFGraphvizRenderer.this.graphvizCommand + "] "
94                                      + line);
95                          }
96                      } catch (final IOException ex) {
97                          LOGGER.error(
98                                  "[" + RDFGraphvizRenderer.this.graphvizCommand + "] "
99                                          + ex.getMessage(), ex);
100                     }
101                 }
102 
103             });
104 
105             boolean insideHeader = true;
106             String line;
107             while ((line = in.readLine()) != null) {
108                 if (insideHeader && line.startsWith("<svg")) {
109                     insideHeader = false;
110                 }
111                 if (!insideHeader) {
112                     out.append(line).append('\n');
113                 }
114             }
115             process.waitFor();
116 
117         } catch (final Throwable ex) {
118             Throwables.propagateIfPossible(ex, IOException.class);
119             Throwables.propagate(ex);
120 
121         } finally {
122             if (dotFile != null) {
123                 dotFile.delete();
124             }
125             if (process != null) {
126                 process.destroy();
127             }
128         }
129 
130         return out;
131     }
132 
133     public void emitDot(final Appendable out, final QuadModel model) throws IOException {
134 
135         // Identify graph nodes using the supplied selector
136         final Set<Resource> nodes = Sets.newHashSet();
137         if (!this.nodeNamespaces.isEmpty()) {
138             for (final Value value : Iterables.concat(model.subjects(), model.objects())) {
139                 if (value instanceof IRI
140                         && this.nodeNamespaces.contains(((IRI) value).getNamespace())) {
141                     nodes.add((IRI) value);
142                 }
143             }
144         }
145         if (!this.nodeTypes.isEmpty()) {
146             for (final Statement stmt : model.filter(null, RDF.TYPE, null)) {
147                 if (stmt.getObject() instanceof Resource
148                         && this.nodeTypes.contains(stmt.getObject())) {
149                     nodes.add(stmt.getSubject());
150                 }
151             }
152         }
153 
154         // Emit header
155         out.append("digraph \"\"\n{\n");
156         out.append("graph [nodesep=1, ranksep=.02, pad=0, margin=0, pack=clust, width=1000];\n");
157         out.append("node  [shape=plaintext, height=0, width=0, fontname=helvetica, fontsize=10];\n");
158         out.append("edge  [arrowsize=.5, fontsize=8, fontname=helvetica, len=1.5];\n\n");
159 
160         // Emit nodes
161         for (final Resource node : nodes) {
162             final String id = hash(node);
163             final Set<Value> types = model.filter(node, RDF.TYPE, null).objects();
164             out.append(id).append(" [");
165             out.append("label=<<table border=\"0\" cellborder=\"0\" cellpadding=\"0\" bgcolor=\"");
166             out.append(select(this.colorMap, types, "#FFFFFF"));
167             out.append("\" href=\"\"><tr><td>");
168             out.append(format(node));
169             out.append("</td></tr></table>>");
170             out.append(" tooltip=\"");
171             final Set<Resource> expandedNodes = Sets.newHashSet();
172             if (!emitDotTooltip(out, model, node, 0, nodes, expandedNodes)) {
173                 out.append(" ");
174             }
175             out.append("\" ");
176             out.append(select(this.styleMap, types, ""));
177             out.append("];\n");
178         }
179 
180         // Emit edges
181         final Set<List<Value>> encounteredEdges = Sets.newHashSet();
182         for (final Statement stmt : model) {
183 
184             // Skip the statement if its property should be ignored
185             if (this.ignoredProperties.contains(stmt.getPredicate())) {
186                 continue;
187             }
188 
189             // Ensure that subject and object are both nodes and the edge was not emitted before
190             if (!(stmt.getObject() instanceof Resource)) {
191                 continue;
192             }
193             final Resource sourceNode = stmt.getSubject();
194             final Resource targetNode = (Resource) stmt.getObject();
195             if (!nodes.contains(sourceNode) || !nodes.contains(targetNode)
196                     || !encounteredEdges.add(ImmutableList.of(sourceNode, targetNode))) {
197                 continue;
198             }
199             final String sourceId = hash(sourceNode);
200             final String targetId = hash(targetNode);
201 
202             // Retrieve the predicates associated to the edge
203             final List<IRI> properties = this.valueComparator.sortedCopy(model.filter(sourceNode,
204                     null, targetNode).predicates());
205 
206             // Select edge style
207             final List<IRI> keys = Lists.newArrayList(properties);
208             for (final Value sourceType : model.filter(sourceNode, RDF.TYPE, null).objects()) {
209                 if (sourceType instanceof IRI) {
210                     keys.add(Statements.VALUE_FACTORY.createIRI(sourceType.stringValue() + "-from"));
211                 }
212             }
213             for (final Value targetType : model.filter(targetNode, RDF.TYPE, null).objects()) {
214                 if (targetType instanceof IRI) {
215                     keys.add(Statements.VALUE_FACTORY.createIRI(targetType.stringValue() + "-to"));
216                 }
217             }
218 
219             // Emit the edge
220             out.append("  ").append(sourceId).append(" -> ").append(targetId).append(" [");
221             out.append("label=<<table border=\"0\" cellborder=\"0\" cellpadding=\"0\" href=\"\" tooltip=\"");
222             out.append(Joiner.on(" | ").join(format(properties)));
223             out.append("\"><tr><td>");
224             out.append(format(properties.get(0)));
225             out.append("</td></tr></table>>");
226             out.append(" ");
227             out.append(select(this.styleMap, keys, ""));
228             out.append("];\n");
229         }
230 
231         // Emit footer
232         out.append("}");
233     }
234 
235     private boolean emitDotTooltip(final Appendable out, final QuadModel model,
236             final Resource node, final int indent, final Set<Resource> excludedNodes,
237             final Set<Resource> expandedNodes) throws IOException {
238         boolean notEmpty = false;
239         for (final IRI pred : this.valueComparator.sortedCopy(model.filter(node, null, null)
240                 .predicates())) {
241             if (this.ignoredProperties.contains(pred)) {
242                 continue;
243             }
244             expandedNodes.add(node);
245             for (final Value object : this.valueComparator.sortedCopy(model.filter(node, pred,
246                     null).objects())) {
247                 if (!excludedNodes.contains(object)) {
248                     out.append(Strings.repeat("    ", indent));
249                     out.append(format(pred));
250                     out.append(" ");
251                     out.append(format(object));
252                     final boolean expanded = expandedNodes.contains(object);
253                     if (expanded) {
254                         out.append(" [...]");
255                     }
256                     out.append(NEWLINE);
257                     notEmpty = true;
258                     if (object instanceof Resource && !expanded
259                             && !this.collapsedProperties.contains(pred)) {
260                         emitDotTooltip(out, model, (Resource) object, indent + 1, excludedNodes,
261                                 expandedNodes);
262                     }
263                 }
264             }
265         }
266         return notEmpty;
267     }
268 
269     private List<String> format(final Iterable<? extends Value> values) {
270         final List<String> result = Lists.newArrayList();
271         for (final Value value : values) {
272             result.add(format(value));
273         }
274         return result;
275     }
276 
277     private String format(final Value value) {
278         if (value instanceof IRI) {
279             final IRI IRI = (IRI) value;
280             final String ns = IRI.getNamespace();
281             final String prefix = this.namespaces.prefixFor(ns);
282             if (prefix == null) {
283                 return escape("<.." + ns.charAt(ns.length() - 1) + IRI.getLocalName() + ">");
284             } else {
285                 return prefix + ":" + escape(IRI.getLocalName());
286             }
287         }
288         return escape(Statements.formatValue(value, this.namespaces));
289     }
290 
291     @Nullable
292     private String shorten(@Nullable final IRI IRI) {
293         if (IRI == null) {
294             return null;
295         }
296         final String prefix = this.namespaces.prefixFor(IRI.getNamespace());
297         if (prefix != null) {
298             return prefix + ':' + IRI.getLocalName();
299         }
300         return "&lt;../" + IRI.getLocalName() + "&gt;";
301     }
302 
303     private static String select(@Nullable final Map<? super Resource, String> map,
304             final Iterable<? extends Value> keys, final String defaultColor) {
305         String color = null;
306         if (map != null) {
307             for (final Value key : keys) {
308                 if (key instanceof Resource) {
309                     final String mappedColor = map.get(key);
310                     if (mappedColor != null) {
311                         if (color == null) {
312                             color = mappedColor;
313                         } else {
314                             break;
315                         }
316                     }
317                 }
318             }
319         }
320         return color != null ? color : defaultColor;
321     }
322 
323     private static String escape(final String string) {
324         return HtmlEscapers.htmlEscaper().escape(string);
325     }
326 
327     private static String hash(final Value value) {
328         final StringBuilder builder = new StringBuilder();
329         if (value instanceof IRI) {
330             builder.append((char) 1);
331             builder.append(value.stringValue());
332         } else if (value instanceof BNode) {
333             builder.append((char) 2);
334             builder.append(((BNode) value).getID());
335         } else if (value instanceof Literal) {
336             final Literal literal = (Literal) value;
337             builder.append((char) 3);
338             builder.append(literal.getLabel());
339             if (literal.getLanguage().isPresent()) {
340                 builder.append((char) 4);
341                 builder.append(literal.getLanguage().get());
342             } else if (!literal.getDatatype().equals(XMLSchema.STRING)) {
343                 builder.append((char) 5);
344                 builder.append(literal.getDatatype().stringValue());
345             }
346         }
347         return "N" + Hash.murmur3(builder.toString()).toString().replace('-', 'x');
348     }
349 
350     public static Builder builder() {
351         return new Builder();
352     }
353 
354     public static final class Builder {
355 
356         @Nullable
357         private Iterable<String> nodeNamespaces;
358 
359         @Nullable
360         private Iterable<? extends Resource> nodeTypes;
361 
362         @Nullable
363         private Iterable<? extends IRI> ignoredProperties;
364 
365         @Nullable
366         private Iterable<? extends IRI> collapsedProperties;
367 
368         @Nullable
369         private Map<? super Resource, String> colorMap;
370 
371         @Nullable
372         private Map<? super Resource, String> styleMap;
373 
374         @Nullable
375         private Namespaces namespaces;
376 
377         @Nullable
378         private Comparator<? super Value> valueComparator;
379 
380         @Nullable
381         private String graphvizCommand;
382 
383         public Builder withNodeNamespaces(@Nullable final Iterable<String> nodeNamespaces) {
384             this.nodeNamespaces = nodeNamespaces;
385             return this;
386         }
387 
388         public Builder withNodeTypes(@Nullable final Iterable<? extends IRI> nodeTypes) {
389             this.nodeTypes = nodeTypes;
390             return this;
391         }
392 
393         public Builder withIgnoredProperties(
394                 @Nullable final Iterable<? extends IRI> ignoredProperties) {
395             this.ignoredProperties = ignoredProperties;
396             return this;
397         }
398 
399         public Builder withCollapsedProperties(
400                 @Nullable final Iterable<? extends IRI> collapsedProperties) {
401             this.collapsedProperties = collapsedProperties;
402             return this;
403         }
404 
405         public Builder withColorMap(@Nullable final Map<? super Resource, String> colorMap) {
406             this.colorMap = colorMap;
407             return this;
408         }
409 
410         public Builder withStyleMap(@Nullable final Map<? super Resource, String> styleMap) {
411             this.styleMap = styleMap;
412             return this;
413         }
414 
415         public Builder withNamespaces(@Nullable final Namespaces namespaces) {
416             this.namespaces = namespaces;
417             return this;
418         }
419 
420         public Builder withValueComparator(
421                 @Nullable final Comparator<? super Value> valueComparator) {
422             this.valueComparator = valueComparator;
423             return this;
424         }
425 
426         public Builder withGraphvizCommand(@Nullable final String graphvizCommand) {
427             this.graphvizCommand = graphvizCommand;
428             return this;
429         }
430 
431         public RDFGraphvizRenderer build() {
432             return new RDFGraphvizRenderer(this);
433         }
434 
435     }
436 
437 }