Class: Ea::Diagram::Extractor
- Inherits:
-
Object
- Object
- Ea::Diagram::Extractor
- Defined in:
- lib/ea/diagram/extractor.rb
Overview
API for extracting and rendering UML diagrams from repositories
This class provides programmatic access to diagram extraction and rendering functionality. It follows API-first architecture, with all business logic in this class rather than in CLI layer.
Constant Summary collapse
- DEFAULT_OPTIONS =
Default rendering options
{ format: "svg", padding: 20, background_color: "#ffffff", grid_visible: false, interactive: false, config_path: nil, }.freeze
- ENV_OPTION_MAP =
{ "LUTAML_DIAGRAM_PADDING" => %i[padding to_i], "LUTAML_DIAGRAM_BG_COLOR" => [:background_color, nil], "LUTAML_DIAGRAM_GRID" => %i[grid_visible boolean], "LUTAML_DIAGRAM_INTERACTIVE" => %i[interactive boolean], "LUTAML_DIAGRAM_CONFIG" => [:config_path, nil], }.freeze
Instance Attribute Summary collapse
-
#options ⇒ Object
readonly
Returns the value of attribute options.
Instance Method Summary collapse
-
#add_class_data(element_data, uml_element) ⇒ Object
Add class attributes and operations.
-
#add_connector_metadata(connector_data, connector) ⇒ Object
Add connector role and multiplicity information.
-
#array_value(value) ⇒ Object
Convert value to array.
- #assign_if_present(hash, key, value, transform = nil) ⇒ Object
-
#build_connectors(diagram, repository, element_map) ⇒ Object
Build connector data from diagram links.
-
#build_element_map(repository) ⇒ Object
Build comprehensive element map keyed by XMI ID Handles classes, packages, instances, and EA prefix normalization.
-
#build_elements(diagram, element_map) ⇒ Object
Build element data from diagram objects.
- #coerce_env_value(value, coercion) ⇒ Object
-
#collect_instances(pkg, map) ⇒ Object
Recursively collect instances from packages.
-
#connector_type(connector) ⇒ Object
Determine connector type.
-
#convert_to_rendering_format(diagram, repository) ⇒ Object
Convert diagram to rendering format.
-
#default_output_path(diagram) ⇒ Object
Default output path for diagram.
-
#diagram_info(diagram) ⇒ Object
Get diagram information.
-
#diagram_object_bounds(obj) ⇒ Object
Convert diagram object bounds to x/y/width/height format.
-
#ea_guid(xmi_id) ⇒ Object
Extract GUID portion from EA XMI ID (strip EAID_, EAPK_ prefix).
-
#element_type(uml_element) ⇒ Object
Determine element type from UML element.
-
#extract_batch(source, diagram_ids, opts = {}) ⇒ Hash
Extract multiple diagrams in batch.
-
#extract_one(source, diagram_id, opts = {}) ⇒ Hash
Extract and render a single diagram.
-
#find_connector(xmi_id, repository) ⇒ Object
Find connector by XMI ID.
-
#find_diagram(repository, diagram_id) ⇒ Object
Find diagram by ID or name.
-
#find_diagram_object_by_element(element_xmi_id, diagram, element_map) ⇒ Object
Find diagram object for element, with EA prefix normalization.
-
#find_element(xmi_id, repository) ⇒ Object
Find UML element by XMI ID.
-
#format_cardinality(cardinality) ⇒ Object
Format cardinality for display.
-
#initialize(options = {}) ⇒ Extractor
constructor
Initialize extractor with options.
-
#list_diagrams(source = nil) ⇒ Hash
List all diagrams in repository.
-
#normalize_coordinates(elements, connectors) ⇒ Object
Normalize EA coordinates to SVG coordinate system EA uses y-up convention; SVG uses y-down.
-
#render_diagram(diagram_data, opts) ⇒ Object
Render diagram to SVG.
- #resolve_options(opts) ⇒ Object
-
#resolve_repository(source) ⇒ Object
Resolve source to a repository object.
-
#sanitize_filename(name) ⇒ Object
Sanitize filename.
Constructor Details
#initialize(options = {}) ⇒ Extractor
Initialize extractor with options
54 55 56 57 |
# File 'lib/ea/diagram/extractor.rb', line 54 def initialize( = {}) @repository = .delete(:repository) @options = () end |
Instance Attribute Details
#options ⇒ Object (readonly)
Returns the value of attribute options.
40 41 42 |
# File 'lib/ea/diagram/extractor.rb', line 40 def @options end |
Instance Method Details
#add_class_data(element_data, uml_element) ⇒ Object
Add class attributes and operations
419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 |
# File 'lib/ea/diagram/extractor.rb', line 419 def add_class_data(element_data, uml_element) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity if uml_element.attributes element_data[:attributes] = uml_element.attributes.map do |attr| { name: attr.name, type: attr.type, visibility: attr.visibility || "public", } end end if uml_element.operations element_data[:operations] = uml_element.operations.map do |op| { name: op.name, return_type: op.return_type, visibility: op.visibility || "public", parameters: op.parameters&.map do |p| { name: p.name, type: p.type } end || [], } end end end |
#add_connector_metadata(connector_data, connector) ⇒ Object
Add connector role and multiplicity information
445 446 447 448 449 450 451 452 453 454 |
# File 'lib/ea/diagram/extractor.rb', line 445 def (connector_data, connector) assign_if_present(connector_data, :source_role, connector.owner_end_attribute_name) assign_if_present(connector_data, :target_role, connector.member_end_attribute_name) assign_if_present(connector_data, :source_multiplicity, connector.owner_end_cardinality, :format) assign_if_present(connector_data, :target_multiplicity, connector.member_end_cardinality, :format) end |
#array_value(value) ⇒ Object
Convert value to array
555 556 557 |
# File 'lib/ea/diagram/extractor.rb', line 555 def array_value(value) value.is_a?(Array) ? value : [value] end |
#assign_if_present(hash, key, value, transform = nil) ⇒ Object
456 457 458 459 460 |
# File 'lib/ea/diagram/extractor.rb', line 456 def assign_if_present(hash, key, value, transform = nil) return unless value hash[key] = transform == :format ? format_cardinality(value) : value end |
#build_connectors(diagram, repository, element_map) ⇒ Object
Build connector data from diagram links
374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 |
# File 'lib/ea/diagram/extractor.rb', line 374 def build_connectors(diagram, repository, element_map) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity diagram.diagram_links.filter_map do |link| # rubocop:disable Metrics/BlockLength connector = find_connector(link.connector_xmi_id, repository) next unless connector if connector.owner_end_xmi_id source_obj = find_diagram_object_by_element( connector.owner_end_xmi_id, diagram, element_map ) end if connector.member_end_xmi_id target_obj = find_diagram_object_by_element( connector.member_end_xmi_id, diagram, element_map ) end connector_data = { id: link.connector_id || link.connector_xmi_id, type: connector_type(connector), element: connector, diagram_link: link, style: link.style, geometry: link.geometry, path: link.path, } # Add source/target positions if source_obj connector_data[:source_element] = diagram_object_bounds(source_obj) end if target_obj connector_data[:target_element] = diagram_object_bounds(target_obj) end # Add role and multiplicity (connector_data, connector) connector_data end end |
#build_element_map(repository) ⇒ Object
Build comprehensive element map keyed by XMI ID Handles classes, packages, instances, and EA prefix normalization
308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 |
# File 'lib/ea/diagram/extractor.rb', line 308 def build_element_map(repository) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength map = {} repository.classes_index.each { |c| map[c.xmi_id] = c } repository.packages_index.each { |p| map[p.xmi_id] = p } # Collect instances from packages recursively document = repository.document document.packages&.each { |pkg| collect_instances(pkg, map) } # Add EA prefix-normalized entries (EAID_ <-> EAPK_ etc.) prefix_normalized = {} map.each do |xmi_id, element| guid = ea_guid(xmi_id) prefix_normalized["EAID_#{guid}"] = element prefix_normalized["EAPK_#{guid}"] = element end map.merge!(prefix_normalized) map end |
#build_elements(diagram, element_map) ⇒ Object
Build element data from diagram objects
341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 |
# File 'lib/ea/diagram/extractor.rb', line 341 def build_elements(diagram, element_map) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity diagram.diagram_objects.filter_map do |obj| uml_element = element_map[obj.object_xmi_id] next unless uml_element element_data = { id: obj.diagram_object_id || obj.object_xmi_id, type: element_type(uml_element), name: uml_element.name, x: obj.left || 0, y: obj.top || 0, width: ((obj.right || 0) - (obj.left || 0)).abs.nonzero? || 120, height: ((obj.bottom || 0) - (obj.top || 0)).abs.nonzero? || 80, style: obj.style, } # Add stereotype if uml_element.stereotype element_data[:stereotype] = array_value(uml_element.stereotype).first end # Add class-specific data if element_data[:type] == "class" add_class_data(element_data, uml_element) end element_data end end |
#coerce_env_value(value, coercion) ⇒ Object
197 198 199 200 201 202 203 |
# File 'lib/ea/diagram/extractor.rb', line 197 def coerce_env_value(value, coercion) case coercion when :to_i then value.to_i when :boolean then value == "true" else value end end |
#collect_instances(pkg, map) ⇒ Object
Recursively collect instances from packages
335 336 337 338 |
# File 'lib/ea/diagram/extractor.rb', line 335 def collect_instances(pkg, map) pkg.instances&.each { |i| map[i.xmi_id] = i } pkg.packages&.each { |p| collect_instances(p, map) } end |
#connector_type(connector) ⇒ Object
Determine connector type
513 514 515 516 517 518 519 520 |
# File 'lib/ea/diagram/extractor.rb', line 513 def connector_type(connector) case connector when Lutaml::Uml::Association then "association" when Lutaml::Uml::Generalization then "generalization" when Lutaml::Uml::Dependency then "dependency" else "connector" end end |
#convert_to_rendering_format(diagram, repository) ⇒ Object
Convert diagram to rendering format
242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/ea/diagram/extractor.rb', line 242 def convert_to_rendering_format(diagram, repository) element_map = build_element_map(repository) elements = build_elements(diagram, element_map) connectors = build_connectors(diagram, repository, element_map) # Normalize coordinates to EA SVG format (y-flipped, origin-based) normalized = normalize_coordinates(elements, connectors) { name: diagram.name, elements: normalized[:elements], connectors: normalized[:connectors], } end |
#default_output_path(diagram) ⇒ Object
Default output path for diagram
540 541 542 |
# File 'lib/ea/diagram/extractor.rb', line 540 def default_output_path(diagram) "#{sanitize_filename(diagram.name)}.svg" end |
#diagram_info(diagram) ⇒ Object
Get diagram information
528 529 530 531 532 533 534 535 536 537 |
# File 'lib/ea/diagram/extractor.rb', line 528 def diagram_info(diagram) { xmi_id: diagram.xmi_id, name: diagram.name, type: diagram.diagram_type, package: diagram.package_name || "Unknown", objects: diagram.diagram_objects&.size || 0, links: diagram.diagram_links&.size || 0, } end |
#diagram_object_bounds(obj) ⇒ Object
Convert diagram object bounds to x/y/width/height format
487 488 489 490 491 492 493 494 495 496 497 498 |
# File 'lib/ea/diagram/extractor.rb', line 487 def diagram_object_bounds(obj) left = obj.left || 0 top = obj.top || 0 right = obj.right || (left + 120) bottom = obj.bottom || (top + 80) { x: left, y: top, width: (right - left).abs, height: (bottom - top).abs, } end |
#ea_guid(xmi_id) ⇒ Object
Extract GUID portion from EA XMI ID (strip EAID_, EAPK_ prefix)
330 331 332 |
# File 'lib/ea/diagram/extractor.rb', line 330 def ea_guid(xmi_id) xmi_id.sub(/\A(EAID|EAPK)_/, "") end |
#element_type(uml_element) ⇒ Object
Determine element type from UML element
501 502 503 504 505 506 507 508 509 510 |
# File 'lib/ea/diagram/extractor.rb', line 501 def element_type(uml_element) case uml_element when Lutaml::Uml::UmlClass then "class" when Lutaml::Uml::Package then "package" when Lutaml::Uml::DataType then "datatype" when Lutaml::Uml::Enum then "enumeration" when Lutaml::Uml::Instance then "instance" else "unknown" end end |
#extract_batch(source, diagram_ids, opts = {}) ⇒ Hash
Extract multiple diagrams in batch
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 |
# File 'lib/ea/diagram/extractor.rb', line 142 def extract_batch(source, diagram_ids, opts = {}) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength merged_opts = @options.merge(opts) output_dir = merged_opts[:output_dir] || "." # Create output directory if needed FileUtils.mkdir_p(output_dir) results = diagram_ids.map do |diagram_id| output_path = File.join(output_dir, "#{sanitize_filename(diagram_id)}.svg") extract_one(source, diagram_id, merged_opts.merge(output: output_path)) end successful = results.count { |r| r[:success] } failed = results.count { |r| !r[:success] } { success: failed.zero?, results: results, summary: { total: diagram_ids.size, successful: successful, failed: failed, }, } rescue StandardError => e { success: false, message: "Batch extraction failed: #{e.}", error: e, } end |
#extract_one(source, diagram_id, opts = {}) ⇒ Hash
Extract and render a single diagram
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'lib/ea/diagram/extractor.rb', line 66 def extract_one(source, diagram_id, opts = {}) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength merged_opts = @options.merge(opts) repository = resolve_repository(source) # Find diagram diagram = find_diagram(repository, diagram_id) unless diagram return { success: false, message: "Diagram not found: #{diagram_id}", available: repository.all_diagrams.map(&:name), } end # Convert to rendering format diagram_data = convert_to_rendering_format(diagram, repository) # Render svg_content = render_diagram(diagram_data, merged_opts) # Determine output path output_path = merged_opts[:output] # Write file if output path specified File.write(output_path, svg_content) if output_path result = { success: true, diagram: diagram_info(diagram), format: merged_opts[:format], message: "Diagram rendered successfully", } # Include path if file was written result[:path] = output_path if output_path # Include SVG content if no output file (for testing) result[:svg_content] = svg_content unless output_path result rescue StandardError => e { success: false, message: "Failed to extract diagram: #{e.}", error: e, } end |
#find_connector(xmi_id, repository) ⇒ Object
Find connector by XMI ID
469 470 471 |
# File 'lib/ea/diagram/extractor.rb', line 469 def find_connector(xmi_id, repository) repository.associations_index.find { |a| a.xmi_id == xmi_id } end |
#find_diagram(repository, diagram_id) ⇒ Object
Find diagram by ID or name
224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 |
# File 'lib/ea/diagram/extractor.rb', line 224 def find_diagram(repository, diagram_id) # Try exact match by name first diagram = repository.find_diagram(diagram_id) return diagram if diagram all_diagrams = repository.all_diagrams # Try exact match by XMI ID diagram = all_diagrams.find { |d| d.xmi_id == diagram_id } return diagram if diagram # Try partial name match (case-insensitive) all_diagrams.find do |d| d.name.downcase.include?(diagram_id.downcase) end end |
#find_diagram_object_by_element(element_xmi_id, diagram, element_map) ⇒ Object
Find diagram object for element, with EA prefix normalization
474 475 476 477 478 479 480 481 482 483 484 |
# File 'lib/ea/diagram/extractor.rb', line 474 def find_diagram_object_by_element(element_xmi_id, diagram, element_map) # The element_map has normalized keys, find the original XMI ID element = element_map[element_xmi_id] return nil unless element # Find the diagram object that references this element diagram.diagram_objects.find do |obj| obj.object_xmi_id == element_xmi_id || element_map[obj.object_xmi_id] == element end end |
#find_element(xmi_id, repository) ⇒ Object
Find UML element by XMI ID
463 464 465 466 |
# File 'lib/ea/diagram/extractor.rb', line 463 def find_element(xmi_id, repository) repository.classes_index.find { |c| c.xmi_id == xmi_id } || repository.packages_index.find { |p| p.xmi_id == xmi_id } end |
#format_cardinality(cardinality) ⇒ Object
Format cardinality for display
550 551 552 |
# File 'lib/ea/diagram/extractor.rb', line 550 def format_cardinality(cardinality) cardinality.to_s end |
#list_diagrams(source = nil) ⇒ Hash
List all diagrams in repository
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/ea/diagram/extractor.rb', line 118 def list_diagrams(source = nil) # rubocop:disable Metrics/MethodLength repository = resolve_repository(source) diagrams = repository.all_diagrams { success: true, count: diagrams.size, diagrams: diagrams.map { |d| diagram_info(d) }, } rescue StandardError => e { success: false, message: "Failed to list diagrams: #{e.}", error: e, } end |
#normalize_coordinates(elements, connectors) ⇒ Object
Normalize EA coordinates to SVG coordinate system EA uses y-up convention; SVG uses y-down. Also shifts all coordinates so minimum x,y is at padding offset.
260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 |
# File 'lib/ea/diagram/extractor.rb', line 260 def normalize_coordinates(elements, connectors) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity if elements.empty? return { elements: elements, connectors: connectors } end padding = 10 # Find bounding box in EA coordinate space min_left = elements.map { |e| e[:x] }.min max_top = elements.map { |e| e[:y] }.max # In EA: y increases upward, top > bottom, height = top - bottom # For SVG: flip y so y increases downward # After negation, the element with max EA y maps to the smallest SVG y. # Shift so that smallest SVG y maps to padding. x_offset = min_left - padding y_offset = -max_top - padding normalized_elements = elements.map do |e| e.merge( x: e[:x] - x_offset, y: -e[:y] - y_offset, ) end normalized_connectors = connectors.map do |c| c = c.dup if c[:source_element] src = c[:source_element].dup src[:x] = src[:x] - x_offset src[:y] = -src[:y] - y_offset c[:source_element] = src end if c[:target_element] tgt = c[:target_element].dup tgt[:x] = tgt[:x] - x_offset tgt[:y] = -tgt[:y] - y_offset c[:target_element] = tgt end c end { elements: normalized_elements, connectors: normalized_connectors } end |
#render_diagram(diagram_data, opts) ⇒ Object
Render diagram to SVG
523 524 525 |
# File 'lib/ea/diagram/extractor.rb', line 523 def render_diagram(diagram_data, opts) Ea::Diagram.render(diagram_data, opts) end |
#resolve_options(opts) ⇒ Object
184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/ea/diagram/extractor.rb', line 184 def (opts) resolved = DEFAULT_OPTIONS.dup ENV_OPTION_MAP.each do |env_key, (option_key, coercion)| env_value = ENV.fetch(env_key, nil) next unless env_value resolved[option_key] = coerce_env_value(env_value, coercion) end resolved.merge(opts) end |
#resolve_repository(source) ⇒ Object
Resolve source to a repository object
209 210 211 212 213 214 215 216 217 218 219 220 221 |
# File 'lib/ea/diagram/extractor.rb', line 209 def resolve_repository(source) return @repository if source.nil? return source unless source.is_a?(String) raise "File not found: #{source}" unless File.exist?(source) begin require "lutaml/uml_repository" Lutaml::UmlRepository::Repository.from_package(source) rescue LoadError raise "Cannot load LUR file: lutaml/uml_repository gem is required" end end |
#sanitize_filename(name) ⇒ Object
Sanitize filename
545 546 547 |
# File 'lib/ea/diagram/extractor.rb', line 545 def sanitize_filename(name) name.gsub(/[^a-zA-Z0-9_-]/, "_") end |