# frozen_string_literal: true #========================== # svg_graph.rb #========================== # # Parses an element list into an SVG tree. # Copyright (c) 2007-2023 Yoichiro Hasebe require "tempfile" require_relative 'base_graph' require_relative 'utils' module RSyntaxTree class SVGGraph < BaseGraph attr_accessor :width, :height def initialize(element_list, params, global) super(element_list, params, global) @height = 0 @width = 0 @extra_lines = [] @fontset = params[:fontset] @fontsize = params[:fontsize] @transparent = params[:transparent] @color = params[:color] @fontstyle = params[:fontstyle] @margin = params[:margin].to_i @polyline = params[:polyline] @line_styles = "\n" @polyline_styles = "\n" @polygon_styles = "\n" @text_styles = "CONTENT\n" @tree_data = String.new @visited_x = {} @visited_y = {} @global = global end def svg_data metrics = parse_list @height = if @element_list.elements.size == 1 metrics[:height] else metrics[:height] + @margin * 2 end @width = metrics[:width] + @margin * 2 x1 = 0 - @margin y1 = 0 - @margin x2 = @width + @margin y2 = @height + @margin extra_lines = @extra_lines.join("\n") as = @global[:h_gap_between_nodes] / 4 * 0.8 as2 = @global[:h_gap_between_nodes] / 2 * 0.8 as4 = @global[:h_gap_between_nodes] * 1.2 header = <<~HDR HDR rect = <<~RCT " RCT footer = "" if @transparent header + @tree_data + extra_lines + footer else header + rect + @tree_data + extra_lines + footer end end def draw_direct_line(s_x, s_y, t_x, t_y, s_arrow = false, t_arrow = false) @extra_lines << generate_connector(s_x, s_y, t_x, t_y, @col_extra, false, s_arrow, t_arrow) end def draw_a_path(s_x, s_y, t_x, t_y, target_arrow = :none) x_spacing = @global[:h_gap_between_nodes] * 1.25 y_spacing = @global[:height_connector] * 0.75 ymax = [s_y, t_y].max new_y = if ymax < @height @height + y_spacing else ymax + y_spacing end if @visited_x[s_x] new_s_x = s_x - x_spacing * @visited_x[s_x] @visited_x[s_x] += 1 else new_s_x = s_x @visited_x[s_x] = 1 end if @visited_x[t_x] new_t_x = t_x - x_spacing * @visited_x[t_x] @visited_x[t_x] += 1 else new_t_x = t_x @visited_x[t_x] = 1 end dashed = true if target_arrow == :none case target_arrow when :single @extra_lines << generate_line(new_s_x, s_y, new_s_x, new_y, @col_path, dashed) @extra_lines << generate_line(new_s_x, s_y, new_s_x, new_y, @col_path, dashed) @extra_lines << generate_line(new_s_x, new_y, new_t_x, new_y, @col_path, dashed) @extra_lines << generate_line(new_t_x, new_y, new_t_x, t_y, @col_path, dashed, true) when :double @extra_lines << generate_line(new_s_x, new_y, new_s_x, s_y, @col_path, dashed, true) @extra_lines << generate_line(new_s_x, new_y, new_t_x, new_y, @col_path, dashed) @extra_lines << generate_line(new_t_x, new_y, new_t_x, t_y, @col_path, dashed, true) else @extra_lines << generate_line(new_s_x, s_y, new_s_x, new_y, @col_path, dashed) @extra_lines << generate_line(new_s_x, new_y, new_t_x, new_y, @col_path, dashed) @extra_lines << generate_line(new_t_x, new_y, new_t_x, t_y, @col_path, dashed) end @height = new_y if new_y > @height end def draw_element(element) top = element.vertical_indent left = element.horizontal_indent right = left + element.content_width txt_pos = left + (right - left) / 2 col = if element.type == ETYPE_LEAF @col_leaf else @col_node end text_data = @text_styles.sub(/COLOR/, col) text_data = text_data.sub(/fontsize/, @fontsize.to_s + "px;") text_x = txt_pos - element.content_width / 2 text_y = top + @global[:single_line_height] - @global[:height_connector_to_text] text_data = text_data.sub(/X_VALUE/, text_x.to_s) text_data = text_data.sub(/Y_VALUE/, text_y.to_s) new_text = +"" this_x = 0 this_y = 0 bc = { x: text_x - @global[:h_gap_between_nodes] / 2, y: top, width: element.content_width + @global[:h_gap_between_nodes], height: nil } element.content.each_with_index do |l, idx| case l[:type] when :border, :bborder x1 = text_x if idx.zero? text_y -= l[:height] else text_y += l[:height] end y1 = text_y - @global[:single_line_height] / 8 x2 = text_x + element.content_width y2 = y1 case l[:type] when :border stroke_width = FONT_SCALING when :bborder stroke_width = FONT_SCALING * 2 end @extra_lines << "" else if element.enclosure == :brackets this_x = txt_pos - element.content_width / 2 else ewidth = 0 l[:elements].each do |e| ewidth += e[:width] end this_x = txt_pos - (ewidth / 2) end text_y += l[:elements].map { |e| e[:height] }.max if idx != 0 l[:elements].each do |e| escaped_text = e[:text].gsub('>', '>').gsub('<', '<'); decorations = [] decorations << "overline" if e[:decoration].include?(:overline) decorations << "underline" if e[:decoration].include?(:underline) decorations << "line-through" if e[:decoration].include?(:linethrough) decoration = "text-decoration=\"" + decorations.join(" ") + "\"" style = "style=\"" if e[:decoration].include?(:small) style += "font-size: #{(SUBSCRIPT_CONST.to_f * 100).to_i}%; " this_y = text_y - ((@global[:single_x_metrics].height - @global[:single_x_metrics].height * SUBSCRIPT_CONST) / 4) + 2 elsif e[:decoration].include?(:superscript) style += "font-size: #{(SUBSCRIPT_CONST.to_f * 100).to_i}%; " this_y = text_y - (@global[:single_x_metrics].height / 4) + 1 elsif e[:decoration].include?(:subscript) style += "font-size: #{(SUBSCRIPT_CONST.to_f * 100).to_i}%; " this_y = text_y + 4 else this_y = text_y end style += "font-weight: bold; " if e[:decoration].include?(:bold) || e[:decoration].include?(:bolditalic) style += "font-style: italic; " if e[:decoration].include?(:italic) || e[:decoration].include?(:bolditalic) style += "\"" case @fontstyle when /(?:cjk)/ fontstyle = "'WenQuanYi Zen Hei', 'Noto Sans', OpenMoji, 'OpenMoji Color', 'OpenMoji Black', sans-serif" when /(?:mono)/ fontstyle = if e[:cjk] "'Noto Sans JP', 'Noto Sans Mono SemiCondensed', OpenMoji, 'OpenMoji Color', 'OpenMoji Black', sans-serif" else "'Noto Sans Mono SemiCondensed', 'Noto Sans JP', OpenMoji, 'OpenMoji Color', 'OpenMoji Black', sans-serif" end when /(?:sans)/ fontstyle = if e[:cjk] "'Noto Sans JP', 'Noto Sans', OpenMoji, 'OpenMoji Color', 'OpenMoji Black', sans-serif" else "'Noto Sans', 'Noto Sans JP', OpenMoji, 'OpenMoji Color', 'OpenMoji Black', sans-serif" end when /(?:serif)/ fontstyle = if e[:cjk] "'Noto Serif JP', 'Noto Serif', OpenMoji, 'OpenMoji Color', 'OpenMoji Black', serif" else "'Noto Serif', 'Noto Serif JP', OpenMoji, 'OpenMoji Color', 'OpenMoji Black', serif" end end if e[:decoration].include?(:box) || e[:decoration].include?(:circle) || e[:decoration].include?(:bar) enc_height = e[:height] enc_y = this_y - e[:height] * 0.8 + FONT_SCALING enc_width = e[:width] enc_x = this_x if e[:decoration].include?(:hatched) case element.type when ETYPE_LEAF fill = if @color "url(#hatchForLeaf)" else "url(#hatchBlack)" end when ETYPE_NODE fill = if @color "url(#hatchForNode)" else "url(#hatchBlack)" end end else fill = "none" end enc = nil stroke_width = if e[:decoration].include?(:bstroke) FONT_SCALING * 2.5 else FONT_SCALING end if e[:decoration].include?(:box) enc = "\n" elsif e[:decoration].include?(:circle) enc = "\n" elsif e[:decoration].include?(:bar) x1 = enc_x y1 = enc_y + enc_height / 2 x2 = enc_x + enc_width y2 = y1 ar_hwidth = e[:width] / 4.0 bar = "\n" @extra_lines << bar if e[:decoration].include?(:arrow_to_l) l_arrowhead = "\n" @extra_lines << l_arrowhead end if e[:decoration].include?(:arrow_to_r) r_arrowhead = "\n" @extra_lines << r_arrowhead end end @extra_lines << enc if enc this_x += if e[:text].size == 1 (e[:height] - e[:content_width]) / 2 else @global[:width_half_x] / 2 end new_text << set_tspan(this_x, this_y, style, decoration, fontstyle, escaped_text) this_x += if e[:text].size == 1 e[:content_width] + (e[:height] - e[:content_width]) / 2 else e[:content_width] + @global[:width_half_x] / 2 end elsif e[:decoration].include?(:whitespace) this_x += e[:width] next else new_text << set_tspan(this_x, this_y, style, decoration, fontstyle, escaped_text) this_x += e[:width] end end end @height = text_y if text_y > @height end bc[:y] = bc[:y] + @global[:height_connector_to_text] * 3 / 4 bc[:height] = text_y - bc[:y] + @global[:height_connector_to_text] case element.enclosure when :brackets @extra_lines << generate_line(bc[:x], bc[:y], bc[:x] + @global[:h_gap_between_nodes] / 2, bc[:y], col) @extra_lines << generate_line(bc[:x], bc[:y], bc[:x], bc[:y] + bc[:height], col) @extra_lines << generate_line(bc[:x], bc[:y] + bc[:height], bc[:x] + @global[:h_gap_between_nodes] / 2, bc[:y] + bc[:height], col) @extra_lines << generate_line(bc[:x] + bc[:width], bc[:y], bc[:x] + bc[:width] - @global[:h_gap_between_nodes] / 2, bc[:y], col) @extra_lines << generate_line(bc[:x] + bc[:width], bc[:y], bc[:x] + bc[:width], bc[:y] + bc[:height], col) @extra_lines << generate_line(bc[:x] + bc[:width], bc[:y] + bc[:height], bc[:x] + bc[:width] - @global[:h_gap_between_nodes] / 2, bc[:y] + bc[:height], col) when :rectangle @extra_lines << generate_line(bc[:x], bc[:y], bc[:x] + bc[:width], bc[:y], col) @extra_lines << generate_line(bc[:x], bc[:y], bc[:x], bc[:y] + bc[:height], col) @extra_lines << generate_line(bc[:x] + bc[:width], bc[:y], bc[:x] + bc[:width], bc[:y] + bc[:height], col) @extra_lines << generate_line(bc[:x], bc[:y] + bc[:height], bc[:x] + bc[:width], bc[:y] + bc[:height], col) when :brectangle @extra_lines << generate_line(bc[:x], bc[:y], bc[:x] + bc[:width], bc[:y], col, false, false, 2) @extra_lines << generate_line(bc[:x], bc[:y], bc[:x], bc[:y] + bc[:height], col, false, false, 2) @extra_lines << generate_line(bc[:x] + bc[:width], bc[:y], bc[:x] + bc[:width], bc[:y] + bc[:height], col, false, false, 2) @extra_lines << generate_line(bc[:x], bc[:y] + bc[:height], bc[:x] + bc[:width], bc[:y] + bc[:height], col, false, false, 2) end element.content_height = bc[:height] @tree_data += text_data.sub(/CONTENT/, new_text) end def set_tspan(this_x, this_y, style, decoration, fontstyle, text) text.gsub!(/■+/) do |x| num_spaces = x.size "" + "■" * num_spaces + "" end "#{text}\n" end def draw_paths paths = [] path_pool_target = {} path_pool_other = {} path_pool_source = {} path_flags = [] line_pool = {} line_flags = [] elist = @element_list.get_elements elist.each do |element| x0 = element.horizontal_indent - @global[:h_gap_between_nodes] x1 = element.horizontal_indent + element.content_width / 2 x2 = element.horizontal_indent + element.content_width + @global[:h_gap_between_nodes] y0 = element.vertical_indent + @global[:height_connector_to_text] / 2 y1 = element.vertical_indent + element.content_height + @global[:height_connector_to_text] et = element.path et.each do |tr| if /\A-(>)?(\d+)\z/ =~ tr arrow = $1 tr = $2 if line_pool[tr] line_pool[tr] << { x: { left: x0, center: x1, right: x2 }, y: { top: y0, center: y0 + (y1 - y0) / 2, bottom: y1 }, arrow: arrow } else line_pool[tr] = [{ x: { left: x0, center: x1, right: x2 }, y: { top: y0, center: y0 + (y1 - y0) / 2, bottom: y1 }, arrow: arrow }] end line_flags << tr elsif /\A>(\d+)\z/ =~ tr tr = $1 if path_pool_target[tr] path_pool_target[tr] << [x1, y1] else path_pool_target[tr] = [[x1, y1]] end path_flags << tr elsif path_pool_source[tr] if path_pool_other[tr] path_pool_other[tr] << [x1, y1] else path_pool_other[tr] = [[x1, y1]] end path_flags << tr else path_pool_source[tr] = [x1, y1] path_flags << tr end raise RSTError, "Error: input text contains a path having more than two ends:\n > #{tr}" if path_flags.tally.any? { |_k, v| v > 2 } || line_flags.tally.any? { |_k, v| v > 2 } end end path_flags.tally.each do |k, v| raise RSTError, "Error: input text contains a path having only one end:\n > #{k}" if v == 1 end path_pool_source.each do |k, v| path_flags.delete(k) if (targets = path_pool_target[k]) targets.each do |t| paths << { x1: v[0], y1: v[1], x2: t[0], y2: t[1], arrow: :single } end elsif (others = path_pool_other[k]) others.each do |t| paths << { x1: v[0], y1: v[1], x2: t[0], y2: t[1], arrow: :none } end end end path_flags.uniq.each do |k| targets = path_pool_target[k] fst = targets.shift targets.each do |t| paths << { x1: fst[0], y1: fst[1], x2: t[0], y2: t[1], arrow: :double } end end paths.each do |t| draw_a_path(t[:x1], t[:y1], t[:x2], t[:y2], t[:arrow]) end line_pool.each do |_k, v| a = v[0] b = v[1] if a[:y][:top] > b[:y][:bottom] draw_direct_line(a[:x][:center], a[:y][:top], b[:x][:center], b[:y][:bottom], a[:arrow], b[:arrow]) elsif a[:y][:bottom] < b[:y][:top] draw_direct_line(b[:x][:center], b[:y][:top], a[:x][:center], a[:y][:bottom], b[:arrow], a[:arrow]) elsif a[:x][:center] < b[:x][:center] draw_direct_line(a[:x][:right], a[:y][:center], b[:x][:left], b[:y][:center], a[:arrow], b[:arrow]) else draw_direct_line(b[:x][:right], b[:y][:center], a[:x][:left], a[:y][:center], b[:arrow], a[:arrow]) end end paths.size + line_pool.keys.size end def generate_connector(x1, y1, x2, y2, col, dashed = false, s_arrow = false, t_arrow = false, stroke_width = 1) string = if s_arrow && t_arrow "marker-mid='url(#arrowBothways)' " elsif s_arrow "marker-mid='url(#arrowForward)' " elsif t_arrow "marker-mid='url(#arrowBackward)' " else "" end dasharray = dashed ? "stroke-dasharray='8 8'" : "" swidth = FONT_SCALING * stroke_width if s_arrow || t_arrow x_mid = if x2 > x1 x1 + (x2 - x1) / 2 else x1 - (x1 - x2) / 2 end y_mid = if y2 > y1 y1 + (y2 - y1) / 2 else y1 - (y1 - y2) / 2 end "" else "" end end def generate_line(x1, y1, x2, y2, col, dashed = false, arrow = false, stroke_width = 1) string = if arrow "marker-end='url(#arrow)' " else "" end dasharray = dashed ? "stroke-dasharray='8 8'" : "" swidth = FONT_SCALING * stroke_width "" end # Draw a line between child/parent elements def line_to_parent(parent, child) return if child.horizontal_indent.zero? if @polyline chi_x = child.horizontal_indent + child.content_width / 2 chi_y = child.vertical_indent + @global[:height_connector_to_text] / 2 par_x = parent.horizontal_indent + parent.content_width / 2 par_y = parent.vertical_indent + parent.content_height + @global[:height_connector_to_text] mid_x1 = chi_x mid_y1 = par_y + (chi_y - par_y) / 2 mid_x2 = par_x mid_y2 = mid_y1 @tree_data += @polyline_styles.sub(/CHIX/, chi_x.to_s) .sub(/CHIY/, chi_y.to_s) .sub(/MIDX1/, mid_x1.to_s) .sub(/MIDY1/, mid_y1.to_s) .sub(/MIDX2/, mid_x2.to_s) .sub(/MIDY2/, mid_y2.to_s) .sub(/PARX/, par_x.to_s) .sub(/PARY/, par_y.to_s) else x1 = child.horizontal_indent + child.content_width / 2 y1 = child.vertical_indent + @global[:height_connector_to_text] / 2 x2 = parent.horizontal_indent + parent.content_width / 2 y2 = parent.vertical_indent + parent.content_height + @global[:height_connector_to_text] line_data = @line_styles.sub(/X1/, x1.to_s) line_data = line_data.sub(/Y1/, y1.to_s) line_data = line_data.sub(/X2/, x2.to_s) @tree_data += line_data.sub(/Y2/, y2.to_s) end end # Draw a triangle between child/parent elements def triangle_to_parent(parent, child) return if child.horizontal_indent.zero? x1 = child.horizontal_indent y1 = child.vertical_indent + @global[:height_connector_to_text] / 2 x2 = child.horizontal_indent + child.content_width y2 = child.vertical_indent + @global[:height_connector_to_text] / 2 x3 = parent.horizontal_indent + parent.content_width / 2 y3 = parent.vertical_indent + parent.content_height + @global[:height_connector_to_text] polygon_data = @polygon_styles.sub(/X1/, x1.to_s) polygon_data = polygon_data.sub(/Y1/, y1.to_s) polygon_data = polygon_data.sub(/X2/, x2.to_s) polygon_data = polygon_data.sub(/Y2/, y2.to_s) polygon_data = polygon_data.sub(/X3/, x3.to_s) @tree_data += polygon_data.sub(/Y3/, y3.to_s) end end end