From 47703715f52b579eb8cfed7b0d5eebea1b73d7d2 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Wed, 7 Oct 2026 14:30:16 +0800 Subject: [PATCH 1/2] ElkEngine: in-process diagram layout via elkrb (issue #51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formatter::Elk builds an Elkrb::Graph from the document (a node per classifier labelled with name, definition and members; edges for associations and class parents), lays it out in-process through Elkrb::LayoutEngine (pure-Ruby ELK, layered algorithm) and renders standalone SVG (nodes as rounded rects with text labels, edges as polylines with per-type arrow markers). No external tools. The `lutaml generate --layout elk` CLI option selects it; graphviz remains the default. elkrb is a soft dependency (Gemfile-only for now): without it, Formatter::Elk raises with installation guidance — whether to promote it to a gemspec dependency is a separate decision. The marker vocabulary lives in ElkSvgMarkers; the renderer emits conformance-shaped SVG (well-formed XML, escaped text, marker defs). --- Gemfile | 7 +- lib/lutaml/lml/cli.rb | 9 +- lib/lutaml/lml/formatter.rb | 1 + lib/lutaml/lml/formatter/elk.rb | 22 ++++ lib/lutaml/lml/layout.rb | 4 + lib/lutaml/lml/layout/elk_engine.rb | 15 +++ lib/lutaml/lml/layout/elk_graph_builder.rb | 128 +++++++++++++++++++++ lib/lutaml/lml/layout/elk_svg_markers.rb | 45 ++++++++ lib/lutaml/lml/layout/elk_svg_renderer.rb | 119 +++++++++++++++++++ spec/lutaml/lml/elk_layout_spec.rb | 51 ++++++++ spec/spec_helper.rb | 6 + 11 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 lib/lutaml/lml/formatter/elk.rb create mode 100644 lib/lutaml/lml/layout/elk_engine.rb create mode 100644 lib/lutaml/lml/layout/elk_graph_builder.rb create mode 100644 lib/lutaml/lml/layout/elk_svg_markers.rb create mode 100644 lib/lutaml/lml/layout/elk_svg_renderer.rb create mode 100644 spec/lutaml/lml/elk_layout_spec.rb diff --git a/Gemfile b/Gemfile index 8a21856..d17cab1 100644 --- a/Gemfile +++ b/Gemfile @@ -6,4 +6,9 @@ gemspec gem "rspec" gem "rubocop" -gem "rake" \ No newline at end of file +gem "rake" +# ELK layout for the `--layout elk` diagram engine (soft dependency: +# the gemspec does not require it; Formatter::Elk guides installation) +gem "elkrb", "~> 1.0" +# Diagram output conformance gates (spec/lutaml/lml/svg_conformance_spec.rb) +gem "svg_conform" \ No newline at end of file diff --git a/lib/lutaml/lml/cli.rb b/lib/lutaml/lml/cli.rb index ae4945f..ca876c2 100644 --- a/lib/lutaml/lml/cli.rb +++ b/lib/lutaml/lml/cli.rb @@ -37,7 +37,12 @@ class LmlCommands < Thor def initialize(*args) super - @formatter = ::Lutaml::Formatter::Graphviz.new if defined?(::Lutaml::Formatter::Graphviz) + @formatter = case options[:layout] + when 'elk' + ::Lutaml::Formatter::Elk.new + else + ::Lutaml::Formatter::Graphviz.new + end @out_object = $stdout end @@ -71,6 +76,8 @@ def initialize(*args) desc: 'Node attributes (key=value,key2=value2)' method_option :all, type: :string, aliases: '-a', desc: 'Set attributes for graph, edge, and node' + method_option :layout, type: :string, enum: %w[graphviz elk], default: 'graphviz', + desc: 'Layout engine (elk requires the elkrb gem)' def generate(*paths) assert_input_paths(paths) diff --git a/lib/lutaml/lml/formatter.rb b/lib/lutaml/lml/formatter.rb index 70781c8..d25f5af 100644 --- a/lib/lutaml/lml/formatter.rb +++ b/lib/lutaml/lml/formatter.rb @@ -4,5 +4,6 @@ module Lutaml module Formatter autoload :Base, "lutaml/lml/formatter/base" autoload :Graphviz, "lutaml/lml/formatter/graphviz" + autoload :Elk, "lutaml/lml/formatter/elk" end end diff --git a/lib/lutaml/lml/formatter/elk.rb b/lib/lutaml/lml/formatter/elk.rb new file mode 100644 index 0000000..5588d58 --- /dev/null +++ b/lib/lutaml/lml/formatter/elk.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Lutaml + module Formatter + # ELK-layout diagram formatter: builds an Elkrb::Graph from the + # document, lays it out in-process (no external tools) and emits SVG. + class Elk + VALID_TYPES = %i[svg].freeze + + attr_reader :type + + def initialize(_attributes = {}) + @type = :svg + end + + def format(document) + graph = Lutaml::Layout::ElkGraphBuilder.new.build(document) + Lutaml::Layout::ElkEngine.new(input: graph).render(@type) + end + end + end +end diff --git a/lib/lutaml/lml/layout.rb b/lib/lutaml/lml/layout.rb index d1502ad..51bde0d 100644 --- a/lib/lutaml/lml/layout.rb +++ b/lib/lutaml/lml/layout.rb @@ -4,5 +4,9 @@ module Lutaml module Layout autoload :Engine, "lutaml/lml/layout/engine" autoload :GraphVizEngine, "lutaml/lml/layout/graph_viz_engine" + autoload :ElkEngine, "lutaml/lml/layout/elk_engine" + autoload :ElkGraphBuilder, "lutaml/lml/layout/elk_graph_builder" + autoload :ElkSvgRenderer, "lutaml/lml/layout/elk_svg_renderer" + autoload :ElkSvgMarkers, "lutaml/lml/layout/elk_svg_markers" end end diff --git a/lib/lutaml/lml/layout/elk_engine.rb b/lib/lutaml/lml/layout/elk_engine.rb new file mode 100644 index 0000000..42ae167 --- /dev/null +++ b/lib/lutaml/lml/layout/elk_engine.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Lutaml + module Layout + # In-process layout engine backed by elkrb (pure-Ruby ELK). The input + # is an already-built Elkrb::Graph; rendering produces standalone SVG. + class ElkEngine < Engine + def render(_type) + require 'elkrb' + Elkrb::Layout::LayoutEngine.layout(@input, algorithm: 'layered') + ElkSvgRenderer.new.render(@input) + end + end + end +end diff --git a/lib/lutaml/lml/layout/elk_graph_builder.rb b/lib/lutaml/lml/layout/elk_graph_builder.rb new file mode 100644 index 0000000..468b728 --- /dev/null +++ b/lib/lutaml/lml/layout/elk_graph_builder.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +module Lutaml + module Layout + # Builds an Elkrb::Graph from an LML document: a node per classifier + # (name, definition, members), an edge per association and parent. + class ElkGraphBuilder + CHAR_WIDTH = 7.2 + LINE_HEIGHT = 16.0 + PADDING = 14.0 + MIN_WIDTH = 120.0 + + def build(document) + graph = elkrb_graph + add_entities(graph, document) + add_association_edges(graph, document) + add_parent_edges(graph, document) + graph + end + + private + + def elkrb_graph + require 'elkrb' + Elkrb::Graph::Graph.new(id: 'diagram') + rescue LoadError + raise Error, 'The elkrb gem is required for ELK layout: add `gem "elkrb"` to your Gemfile' + end + + def entities(document) + collect = lambda do |scope| + [scope.classes, scope.enums, scope.primitives, scope.data_types] + end + top = collect.call(document) + nested = Array(document.packages).flat_map { |pkg| collect.call(pkg) } + (top.flatten + nested.flatten).compact + end + + def node_for(entity) + lines = label_lines(entity) + Elkrb::Graph::Node.new( + id: entity_id(entity), + width: node_width(lines), + height: PADDING + lines.size * LINE_HEIGHT, + labels: lines.each_with_index.map do |line, i| + Elkrb::Graph::Label.new(text: line, x: 0, y: i * LINE_HEIGHT) + end + ) + end + + def label_lines(entity) + lines = [entity.name.to_s] + if entity.respond_to?(:definition) && !entity.definition.to_s.empty? + lines << entity.definition.to_s.gsub("\n", ' ') + end + Array(entity.attributes).each do |attr| + lines << attr_line(attr) + end + lines + end + + def attr_line(attr) + line = "+ #{attr.name}" + line += ": #{attr.type}" if attr.type && !attr.type.to_s.empty? + line + end + + def node_width(lines) + widest = lines.map(&:length).max || 0 + [widest * CHAR_WIDTH + PADDING, MIN_WIDTH].max + end + + def entity_id(entity) + entity.name.to_s.gsub(/[^0-9a-zA-Z_]/, '_') + end + + def associations(document) + Array(document.associations) + end + + def parent_edges(document) + Array(document.classes).filter_map do |klass| + next unless klass.respond_to?(:parent_class) && klass.parent_class + + { owner: klass.parent_class, member: klass.name.to_s, + member_end_type: 'generalization', name: nil } + end + end + + def add_entities(graph, document) + entities(document).each { |entity| graph.children << node_for(entity) } + end + + def add_association_edges(graph, document) + associations(document).each { |assoc| graph.edges << edge_for(association_spec(assoc)) } + end + + def add_parent_edges(graph, document) + parent_edges(document).each { |edge| graph.edges << edge_for(edge) } + end + + def edge_for(spec) + edge = elkrb_edge(spec) + edge.properties = { 'member_end_type' => spec[:member_end_type].to_s } + edge.labels = [Elkrb::Graph::Label.new(text: spec[:name].to_s)] if spec[:name] + edge + end + + def elkrb_edge(spec) + require 'elkrb' + Elkrb::Graph::Edge.new( + id: "edge_#{spec[:owner]}_#{spec[:member]}", + sources: [entity_id_for(spec[:owner])], + targets: [entity_id_for(spec[:member])] + ) + end + + def association_spec(assoc) + { owner: assoc.owner_end.to_s, member: assoc.member_end.to_s, + member_end_type: assoc.member_end_type.to_s, name: assoc.name } + end + + def entity_id_for(name) + name.to_s.gsub(/[^0-9a-zA-Z_]/, '_') + end + end + end +end diff --git a/lib/lutaml/lml/layout/elk_svg_markers.rb b/lib/lutaml/lml/layout/elk_svg_markers.rb new file mode 100644 index 0000000..4017c36 --- /dev/null +++ b/lib/lutaml/lml/layout/elk_svg_markers.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Lutaml + module Layout + # SVG arrow-marker vocabulary and the block for ElkSvgRenderer. + # Marker ids are keyed by association member_end_type. + module ElkSvgMarkers + EDGE_STROKE = '#666666' + + ARROW_MARKERS = { + 'association' => 'arrow', + 'aggregation' => 'diamond', + 'composition' => 'diamond_filled', + 'generalization' => 'triangle', + 'uses' => 'arrow', + 'direct' => 'arrow' + }.freeze + + MARKER_SHAPES = { + 'arrow' => %(), + 'triangle' => %(), + 'diamond' => %(), + 'diamond_filled' => %() + }.freeze + + def arrow_marker(edge) + edge_type = edge.properties&.fetch('member_end_type', nil) + ARROW_MARKERS.fetch(edge_type.to_s, 'arrow') + end + + def defs + MARKER_SHAPES.filter_map { |id, path| marker_def(id, path) }.join("\n") + end + + private + + def marker_def(id, path) + w, h = id.start_with?('diamond') ? %w[14 8] : %w[10 10] + %(#{path}) + end + end + end +end diff --git a/lib/lutaml/lml/layout/elk_svg_renderer.rb b/lib/lutaml/lml/layout/elk_svg_renderer.rb new file mode 100644 index 0000000..187ef5b --- /dev/null +++ b/lib/lutaml/lml/layout/elk_svg_renderer.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module Lutaml + module Layout + # Renders a laid-out Elkrb::Graph (nodes positioned by the layout) as + # standalone SVG: rounded-rect nodes with text-line labels and + # polyline edges with per-type arrow markers. + class ElkSvgRenderer + include ElkSvgMarkers + + EDGE_STROKE = ElkSvgMarkers::EDGE_STROKE + + HEADER = <<~XML + + + XML + NODE_FILL = '#f8f8f8' + NODE_STROKE = '#333333' + + def render(graph) + @graph = graph + @out = +'' + @out << format(HEADER, width: bounds_width, height: bounds_height) + @out << "\n#{defs}\n\n" + nodes + edges + @out << "\n" + @out + end + + private + + def nodes + @graph.children.each { |node| render_node(node) } + end + + def render_node(node) + @out << %() + @out << node_rect(node) + node.labels.each_with_index { |label, i| @out << node_label(label, i) } + @out << '' + end + + def node_rect(node) + %() + end + + def node_label(label, index) + y = ((index + 0.7) * ElkGraphBuilder::LINE_HEIGHT).round(1) + weight = index.zero? ? ' font-weight="bold"' : '' + %(#{escape(label.text)}) + end + + def edges + @graph.edges.each { |edge| render_edge(edge) } + end + + def render_edge(edge) + points = edge_points(edge) + return if points.empty? + + @out << edge_polyline(edge, points) + edge_label(edge, points) + end + + def edge_polyline(edge, points) + pts = points.map { |p| "#{p.x.round(1)},#{p.y.round(1)}" }.join(' ') + %() + end + + def edge_label(edge, points) + label = Array(edge.labels).first + return unless label&.text + + @out << edge_label_text(label, edge_anchor(points)) + end + + def edge_anchor(points) + mid = points[points.size / 2] + { x: (mid.x + 4).round(1), y: (mid.y - 4).round(1) } + end + + def edge_label_text(label, anchor) + %(#{escape(label.text)}) + end + + def edge_points(edge) + sections = Array(edge.sections) + return [] if sections.empty? + + points = [sections.first.start_point] + sections.each do |section| + points.concat(Array(section.bend_points)) + points << section.end_point + end + points.compact + end + + def bounds_width + extent(:x, :width) + 20 + end + + def bounds_height + extent(:y, :height) + 20 + end + + def extent(pos, size) + values = @graph.children.map { |n| n.public_send(pos).to_f + n.public_send(size).to_f } + [values.max || 0, 0].max.round + end + + def escape(text) + text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>') + end + end + end +end diff --git a/spec/lutaml/lml/elk_layout_spec.rb b/spec/lutaml/lml/elk_layout_spec.rb new file mode 100644 index 0000000..e1c127e --- /dev/null +++ b/spec/lutaml/lml/elk_layout_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rexml/document' +require 'stringio' + +RSpec.describe 'ELK diagram layout', :skip_unless_elkrb do + let(:document) do + Lutaml::Lml.parse_document(StringIO.new(<<~LML)) + class Glaze { + attribute color, String + } + class Tile { } + association { + owner_type aggregation + owner Tile + member_type association + member Glaze + } + LML + end + + let(:svg) { Lutaml::Formatter::Elk.new.format(document) } + + it 'positions every node with positive coordinates' do + graph = Lutaml::Layout::ElkGraphBuilder.new.build(document) + Lutaml::Layout::ElkEngine.new(input: graph).render(:svg) + expect(graph.children.map { |n| [n.x.to_f, n.y.to_f] }) + .to all(include(be > 0)) + end + + it 'builds a node per classifier with member labels' do + graph = Lutaml::Layout::ElkGraphBuilder.new.build(document) + glaze = graph.children.find { |n| n.id == 'Glaze' } + expect(glaze.labels.map(&:text)).to eq(['Glaze', '+ color: String']) + expect(graph.edges.size).to eq(1) + end + + it 'emits well-formed SVG containing nodes and edges' do + xml = REXML::Document.new(svg) + expect(xml.get_elements('//rect').size).to eq(2) + expect(xml.get_elements('//polyline').size).to eq(1) + end + + it 'escapes markup-sensitive label text' do + doc = Lutaml::Lml.parse_document(StringIO.new("class A {\n definition \"uses markup\"\n}\n")) + svg = Lutaml::Formatter::Elk.new.format(doc) + expect(svg).not_to include('') + expect(svg).to include('<B>') + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2d7eaeb..b4d4d06 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -17,3 +17,9 @@ def fixtures_path(path) def by_name(entries, name) entries.detect { |n| n.name == name } end + +RSpec.configure do |config| + config.define_derived_metadata(file_path: %r{elk_layout_spec}) do |metadata| + metadata[:skip] = "elkrb gem not available" unless (require "elkrb"; true) rescue false + end +end From 9e1c48d27ba2e5a56be5d8395fd5371e78a512b9 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Wed, 7 Oct 2026 14:39:12 +0800 Subject: [PATCH 2/2] Conformance gate: corpus diagrams validated through svg_conform (issue #52) Every full-document RS 3001 corpus fixture renders through the ELK engine and validates against svg_conform's base profile in CI, so engine or platform regressions surface as spec failures. Runs wherever the svg_conform gem is present (skipped otherwise); nokogiri joins the Gemfile as svg_conform's lutaml-model XML adapter requirement. Also: the graph builder scopes entity collection defensively (packages lack `primitives`). --- Gemfile | 5 ++-- lib/lutaml/lml/layout/elk_graph_builder.rb | 25 +++++++++---------- spec/lutaml/lml/svg_conformance_spec.rb | 28 ++++++++++++++++++++++ spec/spec_helper.rb | 6 +++++ 4 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 spec/lutaml/lml/svg_conformance_spec.rb diff --git a/Gemfile b/Gemfile index d17cab1..6280302 100644 --- a/Gemfile +++ b/Gemfile @@ -10,5 +10,6 @@ gem "rake" # ELK layout for the `--layout elk` diagram engine (soft dependency: # the gemspec does not require it; Formatter::Elk guides installation) gem "elkrb", "~> 1.0" -# Diagram output conformance gates (spec/lutaml/lml/svg_conformance_spec.rb) -gem "svg_conform" \ No newline at end of file +# Diagram output conformance gate (spec/lutaml/lml/svg_conformance_spec.rb) +gem "svg_conform" +gem "nokogiri" # svg_conform resolves its lutaml-model XML adapter through it diff --git a/lib/lutaml/lml/layout/elk_graph_builder.rb b/lib/lutaml/lml/layout/elk_graph_builder.rb index 468b728..1fd2202 100644 --- a/lib/lutaml/lml/layout/elk_graph_builder.rb +++ b/lib/lutaml/lml/layout/elk_graph_builder.rb @@ -2,14 +2,14 @@ module Lutaml module Layout - # Builds an Elkrb::Graph from an LML document: a node per classifier - # (name, definition, members), an edge per association and parent. + # Builds an Elkrb::Graph: a node per classifier (name, definition, + # members); an edge per association and parent specializer. class ElkGraphBuilder CHAR_WIDTH = 7.2 LINE_HEIGHT = 16.0 PADDING = 14.0 MIN_WIDTH = 120.0 - + COLLECTIONS = %i[classes enums primitives data_types].freeze def build(document) graph = elkrb_graph add_entities(graph, document) @@ -28,18 +28,21 @@ def elkrb_graph end def entities(document) - collect = lambda do |scope| - [scope.classes, scope.enums, scope.primitives, scope.data_types] + ([document] + Array(document.packages)).flat_map { |scope| scope_entities(scope) }.flatten.compact + end + + def scope_entities(scope) + COLLECTIONS.filter_map do |collection| + next unless scope.respond_to?(collection) + + scope.public_send(collection) end - top = collect.call(document) - nested = Array(document.packages).flat_map { |pkg| collect.call(pkg) } - (top.flatten + nested.flatten).compact end def node_for(entity) lines = label_lines(entity) Elkrb::Graph::Node.new( - id: entity_id(entity), + id: entity_id_for(entity.name), width: node_width(lines), height: PADDING + lines.size * LINE_HEIGHT, labels: lines.each_with_index.map do |line, i| @@ -120,9 +123,7 @@ def association_spec(assoc) member_end_type: assoc.member_end_type.to_s, name: assoc.name } end - def entity_id_for(name) - name.to_s.gsub(/[^0-9a-zA-Z_]/, '_') - end + def entity_id_for(name) = name.to_s.gsub(/[^0-9a-zA-Z_]/, '_') end end end diff --git a/spec/lutaml/lml/svg_conformance_spec.rb b/spec/lutaml/lml/svg_conformance_spec.rb new file mode 100644 index 0000000..aa93552 --- /dev/null +++ b/spec/lutaml/lml/svg_conformance_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'svg_conform' + +# Issue #52: gate generated diagram output through svg_conform. Every +# full-document RS 3001 corpus fixture is rendered by the ELK engine and +# validated against the SVG base profile, so engine regressions surface +# as spec failures instead of broken user output. +RSpec.describe 'generated diagram conformance', :svg_conform do + fixtures = Dir[File.expand_path('../../fixtures/rs3001/*.lml', __dir__)].sort + + it 'has corpus fixtures to check' do + expect(fixtures).not_to be_empty + end + + fixtures.each do |path| + it "renders #{File.basename(path)} as a conforming SVG" do + document = Lutaml::Lml.parse_document(StringIO.new(File.read(path))) + svg = Lutaml::Formatter::Elk.new.format(document) + report = SvgConform.validate(svg, profile: :base) + unless report.valid? + detail = report.errors.first(3).map(&:message).join('; ') + raise "non-conforming output: #{detail}" + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b4d4d06..fff3243 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -23,3 +23,9 @@ def by_name(entries, name) metadata[:skip] = "elkrb gem not available" unless (require "elkrb"; true) rescue false end end + +RSpec.configure do |config| + config.define_derived_metadata(file_path: %r{svg_conformance_spec}) do |metadata| + metadata[:skip] = "svg_conform gem not available" unless (require "svg_conform"; true) rescue false + end +end