001/* 002 * SPDX-License-Identifier: Apache-2.0 003 * 004 * Copyright 2024-2026 The Enola <https://enola.dev> Authors 005 * 006 * Licensed under the Apache License, Version 2.0 (the "License"); 007 * you may not use this file except in compliance with the License. 008 * You may obtain a copy of the License at 009 * 010 * https://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package dev.enola.thing.gen.graphviz; 019 020import com.google.common.escape.Escaper; 021import com.google.common.html.HtmlEscapers; 022 023import dev.enola.common.context.Context; 024import dev.enola.common.context.TLC; 025import dev.enola.common.convert.ConversionException; 026import dev.enola.thing.KIRI; 027import dev.enola.thing.PredicatesObjects; 028import dev.enola.thing.Thing; 029import dev.enola.thing.gen.Orphanage; 030import dev.enola.thing.gen.ThingsIntoAppendableConverter; 031import dev.enola.thing.impl.OnlyIRIThing; 032import dev.enola.thing.metadata.ThingMetadataProvider; 033import dev.enola.thing.repo.StackedThingProvider; 034import dev.enola.thing.repo.ThingProvider; 035 036import org.jspecify.annotations.Nullable; 037 038import java.io.IOException; 039import java.util.HashSet; 040import java.util.Set; 041 042public class GraphvizGenerator implements ThingsIntoAppendableConverter { 043 044 // TODO Coalesce e.g. enola:parent & enola:children (schema:inverseOf) into single dir=both link 045 // This would be useful e.g. for the TikaMediaTypesThingConverter produced graph diagram 046 // Note that strict digraph graphName { concentrate=true does not do this (because of labels; 047 // see https://stackoverflow.com/a/3463332/421602). 048 049 // TODO Subgraphs? https://graphviz.org/doc/info/lang.html#subgraphs-and-clusters Classes? 050 051 // TODO Links to other Things (not external HTTP) from within nested blank nodes? With ports?? 052 053 private static final int MAX_TEXT_LENGTH = 23; 054 055 public enum Flags implements Context.Key<Boolean> { 056 /** 057 * In "full" mode, we print a table with properties; in "lite" mode we do not. 058 * 059 * <p>Note that many of <a href="https://graphviz.org/docs/layouts/">Graphviz's Layout 060 * Engines</a> don't seem to work well with full mode. 061 */ 062 FULL 063 } 064 065 // NB: RosettaTest#testGraphviz() is the test coverage for this code 066 067 // NB: http://magjac.com/graphviz-visual-editor/ is handy for testing! 068 069 // NB: Because Graphviz (v12) does NOT support rendering A/HREF inside TABLE of LABEL, 070 // we cannot make the propertyIRIs clickable, unfortunately. (Nor any object values which are 071 // e.g. http://... (of ^schema:URL Datatype). 072 073 // NB: We're intentionally *NOT* showing the Datatype of properties (it's "too much"). 074 075 private final ThingMetadataProvider metadataProvider; 076 077 public GraphvizGenerator(ThingMetadataProvider metadataProvider) { 078 this.metadataProvider = metadataProvider; 079 } 080 081 @Override 082 public boolean convertInto(Iterable<Thing> from, Appendable out) 083 throws ConversionException, IOException { 084 Set<String> thingIRIs = new HashSet<>(); 085 var orphanage = new Orphanage(); 086 out.append("digraph {\n"); 087 try (var ctx = TLC.open()) { 088 ctx.push(ThingProvider.class, new StackedThingProvider(from)); 089 for (Thing thing : from) { 090 orphanage.nonOrphan(thing.iri()); 091 printThing(thing, out, orphanage); 092 } 093 for (String orphanIRI : orphanage.orphans()) { 094 var orphanThing = new OnlyIRIThing(orphanIRI); 095 printThing(orphanThing, out, orphanage); 096 } 097 } 098 out.append("}\n"); 099 return true; 100 } 101 102 private void printThing(Thing thing, Appendable out, Orphanage orphanage) throws IOException { 103 boolean full = TLC.optional(Flags.FULL).orElse(false); 104 105 out.append(" \""); 106 out.append(thing.iri()); 107 out.append("\" ["); 108 if (full) out.append("shape=plain "); 109 110 // Nota bene: This is just an approximate heuristic; if there are multiple types, 111 // then we don't know which of them has colors, if any. 112 // TODO We could do better and find the first one with a color, if any.... 113 var types = thing.getThings(KIRI.RDF.TYPE).iterator(); 114 if (types.hasNext()) { 115 printColors(types.next(), out); 116 } else { 117 printColors(thing, out); 118 } 119 120 out.append("URL=\""); 121 out.append(thing.iri()); 122 out.append("\" label="); 123 var metadata = metadataProvider.get(thing, thing.iri()); 124 var label = label(metadata); 125 if (full) { 126 out.append("<"); 127 printNonLinkPropertiesTable(label, thing, out); 128 out.append(">"); 129 } else { 130 out.append("\""); 131 out.append(label); 132 out.append("\""); 133 } 134 out.append("]\n"); 135 136 for (var p : thing.predicateIRIs()) { 137 for (var link : thing.getLinks(p)) { 138 out.append(" \""); 139 out.append(thing.iri()); 140 out.append("\" -> \""); 141 var linkIRI = link.toString(); 142 var linkLabel = label(metadataProvider.get(p)); 143 out.append(linkIRI); 144 out.append("\" [URL=\""); 145 out.append(p); // NOT linkIRI 146 out.append("\" label=\""); 147 out.append(html(linkLabel)); 148 out.append("\"]\n"); 149 orphanage.candidate(linkIRI); 150 } 151 } 152 out.append('\n'); 153 } 154 155 private void printColors(Thing thing, Appendable out) throws IOException { 156 var color = thing.get(KIRI.E.COLOR, String.class); 157 if (color != null) { 158 out.append("style=filled fillcolor="); 159 out.append(color); 160 out.append(' '); 161 } 162 var textColor = thing.get(KIRI.E.TEXT_COLOR, String.class); 163 if (textColor != null) { 164 out.append("fontcolor="); 165 out.append(textColor); 166 out.append(' '); 167 } 168 } 169 170 // NB: This is Graphviz and not an HTML table syntax! 171 // See https://graphviz.org/doc/info/shapes.html#html 172 private void printNonLinkPropertiesTable( 173 @Nullable String thingLabel, PredicatesObjects thing, Appendable out) 174 throws IOException { 175 out.append("<TABLE BORDER=\"0\" CELLBORDER=\"1\" CELLSPACING=\"0\">\n"); 176 if (thingLabel != null) { 177 out.append(" <TR><TD COLSPAN=\"2\">"); 178 out.append(html(thingLabel)); 179 out.append("</TD></TR>\n"); 180 } 181 for (var p : thing.predicateIRIs()) { 182 if (!thing.getLinks(p).isEmpty()) continue; 183 var pLabel = label(metadataProvider.get(p)); 184 out.append(" <TR><TD ALIGN=\"left\">"); 185 out.append(html(pLabel)); 186 out.append("</TD><TD>"); 187 if (thing.isIterable(p)) { 188 var iterable = thing.get(p, Iterable.class); 189 if (iterable != null) { 190 for (var element : iterable) { 191 // TODO Convert using datatype; needs thing.get(p, n, String.class) 192 out.append(html(brief(element.toString()))); 193 out.append("<BR/>"); 194 } 195 } 196 } else if (thing.isStruct(p)) { 197 out.append("..."); // TODO Dig in, or fine as is? 198 } else { 199 var value = thing.getString(p); 200 if (value != null) out.append(html(brief(value))); 201 } 202 out.append("</TD></TR>\n"); 203 } 204 out.append(" </TABLE>"); 205 } 206 207 private String brief(String text) { 208 var trim = text.trim(); 209 if (trim.length() > MAX_TEXT_LENGTH) return trim.substring(0, MAX_TEXT_LENGTH) + "..."; 210 else return trim; 211 } 212 213 private static final Escaper htmlEscaper = HtmlEscapers.htmlEscaper(); 214 215 private String html(String text) { 216 return htmlEscaper.escape(text); 217 } 218}