lib/rgl/rdot.rb

Summary

Maintainability
C
1 day
Test Coverage
A
100%
module RGL

  # This is a modified version of +dot.rb+ from {https://ruby.github.io/rdoc Dave
  # Thomas's rdoc project}. I renamed it to +rdot.rb+ to avoid collision with an
  # installed rdoc/dot.
  #
  # It also supports undirected edges.
  module DOT

    # attributes due to
    # https://www.graphviz.org/pdf/dotguide.pdf
    # January 5, 2015

    # options for node declaration
    NODE_OPTS = [
      'color', # default: black; node shape color
      'colorscheme', # default: X11; scheme for interpreting color names
      'comment', # any string (format-dependent)
      'distortion', # default: 0.0; node distortion for shape=polygon
      'fillcolor', # default: lightgrey/black; node fill color
      'fixedsize', # default: false; label text has no affect on node size
      'fontcolor', # default: black; type face color
      'fontname', # default: Times-Roman; font family
      'fontsize', # default: 14; point size of label
      'group', # name of node's group
      'height', # default: .5; height in inches
      'id', # any string (user-defined output object tags)
      'label', # default: node name; any string
      'labelloc', # default: c; node label vertical alignment
      'layer', # default: overlay range; all, id or id:id
      'margin', # default: 0.11,0.55; space around label
      'nojustify', # default: false; if true, justify to label, not node
      'orientation', # default: 0.0; node rotation angle
      'penwidth', # default: 1.0; width of pen for drawing boundaries, in points
      'peripheries', # shape-dependent number of node boundaries
      'regular', # default: false; force polygon to be regular
      'samplepoints', # default 8 or 20; number vertices to convert circle or ellipse
      'shape', # default: ellipse; node shape; see Section 2.1 and Appendix E
      'shapefile', # external EPSF or SVG custom shape file
      'sides', # default: 4; number of sides for shape=polygon
      'skew', # default: 0.0; skewing of node for shape=polygon
      'style', # graphics options, e.g. bold, dotted, filled; cf. Section 2.3
      'target', # if URL is set, determines browser window for URL
      'tooltip', # default: label, tooltip annotation for node
      'URL', # URL associated with node (format-dependent)
      'width', # default: .75; width in inches
    ].freeze

    # maintained for backward compatibility or rdot internal
    NODE_OPTS_LGCY = [
      'bottomlabel', # auxiliary label for nodes of shape M*
      'bgcolor',
      'rank',
      'toplabel' # auxiliary label for nodes of shape M*
    ].freeze

    # options for edge declaration
    EDGE_OPTS = [
      'arrowhead', # default: normal; style of arrowhead at head end
      'arrowsize', # default: 1.0; scaling factor for arrowheads
      'arrowtail', # default: normal; style of arrowhead at tail end
      'color', # default: black; edge stroke color
      'colorscheme', # default: X11; scheme for interpreting color names
      'comment', # any string (format-dependent)
      'constraint', # default: true use edge to affect node ranking
      'decorate', # if set, draws a line connecting labels with their edges
      'dir', # default: forward; forward, back, both, or none
      'edgeURL', # URL attached to non-label part of edge
      'edgehref', # synonym for edgeURL
      'edgetarget', # if URL is set, determines browser window for URL
      'edgetooltip', # default: label; tooltip annotation for non-label part of edge
      'fontcolor', # default: black type face color
      'fontname', # default: Times-Roman; font family
      'fontsize', # default: 14; point size of label
      'headclip', # default: true; if false, edge is not clipped to head node boundary
      'headhref', # synonym for headURL
      'headlabel', # default: label; placed near head of edge
      'headport', # n,ne,e,se,s,sw,w,nw
      'headtarget', # if headURL is set, determines browser window for URL
      'headtooltip', # default: label; tooltip annotation near head of edge
      'headURL', # URL attached to head label if output format is ismap
      'href', # alias for URL
      'id', # any string (user-defined output object tags)
      'label', # edge label
      'labelangle', # default: -25.0; angle in degrees which head or tail label is rotated off edge
      'labeldistance', # default: 1.0; scaling factor for distance of head or tail label from node
      'labelfloat', # default: false; lessen constraints on edge label placement
      'labelfontcolor', # default: black; type face color for head and tail labels
      'labelfontname', # default: Times-Roman; font family for head and tail labels
      'labelfontsize', # default: 14 point size for head and tail labels
      'labelhref', # synonym for labelURL
      'labelURL', # URL for label, overrides edge URL
      'labeltarget', # if URL or labelURL is set, determines browser window for URL
      'labeltooltip', # default: label; tooltip annotation near label
      'layer', # default: overlay range; all, id or id:id
      'lhead', # name of cluster to use as head of edge
      'ltail', # name of cluster to use as tail of edge
      'minlen', # default: 1 minimum rank distance between head and tail
      'penwidth', # default: 1.0; width of pen for drawing boundaries, in points
      'samehead', # tag for head node; edge heads with the same tag are merged onto the same port
      'sametail', # tag for tail node; edge tails with the same tag are merged onto the same port
      'style', # graphics options, e.g. bold, dotted, filled; cf. Section 2.3
      'weight', # default: 1; integer cost of stretching an edge
      'tailclip', # default: true; if false, edge is not clipped to tail node boundary
      'tailhref', # synonym for tailURL
      'taillabel', # label placed near tail of edge
      'tailport', # n,ne,e,se,s,sw,w,nw
      'tailtarget', # if tailURL is set, determines browser window for URL
      'tailtooltip', # default: label; tooltip annotation near tail of edge
      'tailURL', # URL attached to tail label if output format is ismap
      'target', # if URL is set, determines browser window for URL
      'tooltip' # default: label; tooltip annotation for edge
    ].freeze

    # maintained for backward compatibility or rdot internal
    EDGE_OPTS_LGCY = [].freeze

    # options for graph declaration
    GRAPH_OPTS = [
      'aspect', # controls aspect ratio adjustment
      'bgcolor', # background color for drawing, plus initial fill color
      'center', # default: false; center draing on page
      'clusterrank', # default: local; may be "global" or "none"
      'color', # default: black; for clusters, outline color, and fill color if
               # fillcolor not defined
      'colorscheme', # default: X11; scheme for interpreting color names
      'comment', # any string (format-dependent)
      'compound', # default: false; allow edges between clusters
      'concentrate', # default: false; enables edge concentrators
      'dpi', # default: 96; dots per inch for image output
      'fillcolor', # default: black; cluster fill color
      'fontcolor', # default: black; type face color
      'fontname', # default: Times-Roman; font family
      'fontnames', # svg, ps, gd (SVG only)
      'fontpath', # list of directories to search for fonts
      'fontsize', # default: 14; point size of label
      'id', # any string (user-defined output object tags)
      'label', # any string
      'labeljust', # default: centered; "l" and "r" for left- and right-justified
                   # cluster labels, respectively
      'labelloc', # default: top; "t" and "b" for top- and bottom-justified
                  # cluster labels, respectively
      'landscape', # if true, means orientation=landscape
      'layers', # id:id:id...
      'layersep', # default: : ; specifies separator character to split layers'
      'margin', # default: .5; margin included in page, inches
      'mclimit', # default: 1.0; scale factor for mincross iterations
      'nodesep', # default: .25; separation between nodes, in inches.
      'nojustify', # default: false; if true, justify to label, not graph
      'nslimit', # if set to "f", bounds network simplex iterations by
                 # (f)(number of nodes) when setting x-coordinates
      'nslimit1', # if set to "f", bounds network simplex iterations by
                  # (f)(number of nodes) when ranking nodes
      'ordering', # if "out" out edge order is preserved
      'orientation', # default: portrait; if "rotate" is not used and the value is
                     # "landscape", use landscape orientation
      'outputorder', # default: breadthfirst; or nodesfirst, edgesfirst
      'page', # unit of pagination, e.g. "8.5,11"
      'pagedir', # default: BL; traversal order of pages
      'pencolor', # default: black; color for drawing cluster boundaries
      'penwidth', # default: 1.0; width of pen for drawing boundaries, in points
      'peripheries', # default: 1; shape-dependent number of node boundaries
      'rank', # "same", "min", "max", "source", or "sink"
      'rankdir', # default: TB; "LR" (left to right) or "TB" (top to bottom)
      'ranksep', # default: .75; separation between ranks, in inches.
      'ratio', # approximate aspect ratio desired, "fill" or "auto"
      'remincross', # default: true; whether to run edge crossing minimization
                    # a second time when there are multiple clusters
      'rotate', # If 90, set orientation to landscape
      'samplepoints', # default: 8; number of points used to represent ellipses
                      # and circles on output
      'searchsize', # default: 30; maximum edges with negative cut values to check
                    # when looking for a minimum one during network simplex
      'size', # maximum drawing size, in inches
      'splines', # draw edges as splines, polylines, lines
      'style', # graphics options, e.g. "filled" for clusters
      'stylesheet', # pathname or URL to XML style sheet for SVG
      'target', # if URL is set, determines browser window for URL
      'tooltip', # default: label; tooltip annotation for cluster
      'truecolor', # if set, force 24 bit or indexed color in image output
      'viewport', # clipping window on output
      'URL', # URL associated with graph (format-dependent)
    ].freeze

    # maintained for backward compatibility or rdot internal
    GRAPH_OPTS_LGCY = [
      'layerseq'
    ].freeze

    # Ancestor of Edge, Node, and Graph.
    #
    class Element

      attr_accessor :name, :options

      def initialize(params = {}, option_list = [])
        @name = params['name'] ? params['name'] : nil
        @options = {}

        option_list.each do |i|
          @options[i] = params[i] if params[i]
        end
      end

      private

      # Returns the string given in _id_ within quotes if necessary. Special
      # characters are escaped as necessary.
      #
      def quote_ID(id)
        # Ensure that the ID is a string.
        id = id.to_s

        # Return the ID verbatim if it looks like a name, a number, or HTML.
        return id if id =~ /\A([[:alpha:]_][[:alnum:]_]*|-?(\.[[:digit:]]+|[[:digit:]]+(\.[[:digit:]]*)?)|<.*>)\Z/m && id[-1] != ?\n

        # Return a quoted version of the ID otherwise.
        '"' + id.gsub('\\', '\\\\\\\\').gsub('"', '\\\\"') + '"'
      end

      # Returns the string given in _label_ within quotes if necessary. Special
      # characters are escaped as necessary. Labels get special treatment in
      # order to handle embedded *\n*, *\r*, and *\l* sequences which are copied
      # into the new string verbatim.
      #
      def quote_label(label)
        # Ensure that the label is a string.
        label = label.to_s

        # Return the label verbatim if it looks like a name, a number, or HTML.
        return label if label =~ /\A([[:alpha:]_][[:alnum:]_]*|-?(\.[[:digit:]]+|[[:digit:]]+(\.[[:digit:]]*)?)|<.*>)\Z/m && label[-1] != ?\n

        # Return a quoted version of the label otherwise.
        '"' + label.split(/(\\n|\\r|\\l)/).collect do |part|
          case part
          when "\\n", "\\r", "\\l"
            part
          else
            part.gsub('\\', '\\\\\\\\').gsub('"', '\\\\"').gsub("\n", '\\n')
          end
        end.join + '"'
      end
    end

    # Ports are used when a Node instance has its `shape' option set to
    # _record_ or _Mrecord_. Ports can be nested.
    #
    class Port
      attr_accessor :name, :label, :ports

      # Create a new port with either an optional name and label or a set of
      # nested ports.
      #
      # :call-seq:
      #   new(name = nil, label = nil)
      #   new(ports)
      #
      # A +nil+ value for +name+ is valid; otherwise, it must be a String or it
      # will be interpreted as +ports+.
      #
      def initialize(name_or_ports = nil, label = nil)
        if name_or_ports.nil? || name_or_ports.kind_of?(String)
          @name  = name_or_ports
          @label = label
          @ports = nil
        else
          @ports = name_or_ports
          @name  = nil
          @label = nil
        end
      end

      # Returns a string representation of this port. If ports is a non-empty
      # Enumerable, a nested ports representation is returned; otherwise, a
      # name-label representation is returned.
      #
      def to_s
        if @ports.nil? || @ports.empty?
          n = (name.nil? || name.empty?) ? '' : "<#{name}>"
          n + ((n.empty? || label.nil? || label.empty?) ? '' : ' ') + label.to_s
        else
          '{' + @ports.collect { |p| p.to_s }.join(' | ') + '}'
        end
      end
    end

    # A node representation. Edges are drawn between nodes. The rendering of a
    # node depends upon the options set for it.
    #
    class Node < Element

      attr_accessor :ports

      # Creates a new Node with the _params_ Hash providing settings for all
      # node options. The _option_list_ parameter restricts those options to the
      # list of valid names it contains. The exception to this is the _ports_
      # option which, if specified, must be an Enumerable containing a list of
      # ports.
      #
      def initialize(params = {}, option_list = NODE_OPTS+NODE_OPTS_LGCY)
        super(params, option_list)
        @ports = params['ports'] ? params['ports'] : []
      end

      # Returns a string representation of this node which is consumable by the
      # graphviz tools +dot+ and +neato+. The _leader_ parameter is used to indent
      # every line of the returned string, and the _indent_ parameter is used to
      # additionally indent nested items.
      #
      def to_s(leader = '', indent = '    ')
        label_option = nil

        if @options['shape'] =~ /^M?record$/ && !@ports.empty?
          # Ignore the given label option in this case since the ports should each
          # provide their own name/label.
          label_option = leader + indent + "#{quote_ID('label')} = #{quote_ID(@ports.collect { |port| port.to_s }.join(" | "))}"
        elsif @options['label']
          # Otherwise, use the label when given one.
          label_option = leader + indent + "#{quote_ID('label')} = #{quote_label(@options['label'])}"
        end

        # Convert all the options except `label' and options with nil values
        # straight into name = value pairs. Then toss out any resulting nil
        # entries in the final array.
        stringified_options = @options.collect do |name, val|
          unless name == 'label' || val.nil?
            leader + indent + "#{quote_ID(name)} = #{quote_ID(val)}"
          end
        end.compact

        # Append the specially computed label option.
        stringified_options.push(label_option) unless label_option.nil?

        # Join them all together.
        stringified_options = stringified_options.join(",\n")

        # Put it all together into a single string with indentation and return the
        # result.
        if stringified_options.empty?
          leader + quote_ID(@name) unless @name.nil?
        else
          leader + (@name.nil? ? '' : quote_ID(@name) + " ") + "[\n" +
          stringified_options + "\n" +
          leader + "]"
        end
      end
    end # class Node

    # A graph representation. Whether or not it is rendered as directed or
    # undirected depends on which of the programs *dot* or *neato* is used to
    # process and render the graph.
    #
    class Graph < Element

      # Creates a new Graph with the _params_ Hash providing settings for all
      # graph options. The _option_list_ parameter restricts those options to the
      # list of valid names it contains. The exception to this is the _elements_
      # option which, if specified, must be an Enumerable containing a list of
      # nodes, edges, and/or subgraphs.
      #
      def initialize(params = {}, option_list = GRAPH_OPTS+GRAPH_OPTS_LGCY)
        super(params, option_list)
        @elements   = params['elements'] ? params['elements'] : []
        @dot_string = 'graph'
      end

      # Calls _block_ once for each node, edge, or subgraph contained by this
      # graph, passing the node, edge, or subgraph to the block.
      #
      # :call-seq:
      #   graph.each_element {|element| block} -> graph
      #
      def each_element(&block)
        @elements.each(&block)
        self
      end

      # Adds a new node, edge, or subgraph to this graph.
      #
      # :call-seq:
      #   graph << element -> graph
      #
      def <<(element)
        @elements << element
        self
      end

      alias push <<

      # Removes the most recently added node, edge, or subgraph from this graph
      # and returns it.
      #
      # :call-seq:
      #   graph.pop -> element
      #
      def pop
        @elements.pop
      end

      # Returns a string representation of this graph which is consumable by the
      # graphviz tools +dot+ and +neato+. The _leader_ parameter is used to indent
      # every line of the returned string, and the _indent_ parameter is used to
      # additionally indent nested items.
      #
      def to_s(leader = '', indent = '    ')
        hdr = leader + @dot_string + (@name.nil? ? '' : ' ' + quote_ID(@name)) + " {\n"

        options = @options.to_a.collect do |name, val|
          unless val.nil?
            if name == 'label'
              leader + indent + "#{quote_ID(name)} = #{quote_label(val)}"
            else
              leader + indent + "#{quote_ID(name)} = #{quote_ID(val)}"
            end
          end
        end.compact.join("\n")

        elements = @elements.collect do |element|
          element.to_s(leader + indent, indent)
        end.join("\n\n")

        hdr + (options.empty? ? '' : options + "\n\n") +
        (elements.empty? ? '' : elements + "\n") + leader + "}"
      end
    end # class Graph

    # A digraph is a directed graph representation which is the same as a Graph
    # except that its header in dot notation has an identifier of _digraph_
    # instead of _graph_.
    #
    class Digraph < Graph

      # Creates a new Digraph with the _params_ Hash providing settings for all
      # graph options. The _option_list_ parameter restricts those options to the
      # list of valid names it contains. The exception to this is the _elements_
      # option which, if specified, must be an Enumerable containing a list of
      # nodes, edges, and/or subgraphs.
      #
      def initialize(params = {}, option_list = GRAPH_OPTS+GRAPH_OPTS_LGCY)
        super(params, option_list)
        @dot_string = 'digraph'
      end

    end # class Digraph

    # A subgraph is a nested graph element and is the same as a Graph except
    # that its header in dot notation has an identifier of _subgraph_ instead of
    # _graph_.
    #
    class Subgraph < Graph

      # Creates a new Subgraph with the _params_ Hash providing settings for
      # all graph options. The _option_list_ parameter restricts those options to
      # list of valid names it contains. The exception to this is the _elements_
      # option which, if specified, must be an Enumerable containing a list of
      # nodes, edges, and/or subgraphs.
      #
      def initialize(params = {}, option_list = GRAPH_OPTS+GRAPH_OPTS_LGCY)
        super(params, option_list)
        @dot_string = 'subgraph'
      end

    end # class Subgraph

    # This is an undirected edge representation.
    #
    class Edge < Element

      # A node or subgraph reference or instance to be used as the starting point
      # for an edge.
      attr_accessor :from

      # A node or subgraph reference or instance to be used as the ending point
      # for an edge.
      attr_accessor :to

      # Creates a new Edge with the _params_ Hash providing settings for all
      # edge options. The _option_list_ parameter restricts those options to the
      # list of valid names it contains.
      #
      def initialize(params = {}, option_list = EDGE_OPTS+EDGE_OPTS_LGCY)
        super(params, option_list)
        @from = params['from'] ? params['from'] : nil
        @to   = params['to'] ? params['to'] : nil
      end

      # Returns a string representation of this edge which is consumable by the
      # graphviz tools +dot+ and +neato+. The _leader_ parameter is used to indent
      # every line of the returned string, and the _indent_ parameter is used to
      # additionally indent nested items.
      #
      def to_s(leader = '', indent = '    ')
        stringified_options = @options.collect do |name, val|
          unless val.nil?
            leader + indent + "#{quote_ID(name)} = #{quote_ID(val)}"
          end
        end.compact.join(",\n")

        f_s = @from || ''
        t_s = @to || ''

        if stringified_options.empty?
          leader + quote_ID(f_s) + ' ' + edge_link + ' ' + quote_ID(t_s)
        else
          leader + quote_ID(f_s) + ' ' + edge_link + ' ' + quote_ID(t_s) + " [\n" +
          stringified_options + "\n" +
          leader + "]"
        end
      end

      private

      def edge_link
        '--'
      end

    end # class Edge

    # A directed edge representation otherwise identical to Edge.
    #
    class DirectedEdge < Edge

      private

      def edge_link
        '->'
      end

    end # class DirectedEdge

  end # module DOT

end # module RGL