Class: Ea::Diagram::Extractor

Inherits:
Object
  • Object
show all
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.

Examples:

Extract single diagram

extractor = DiagramExtractor.new
result = extractor.extract_one(
  "model.lur",
  "diagram001",
  output: "diagram.svg"
)

List all diagrams

diagrams = extractor.list_diagrams("model.lur")
diagrams.each { |d| puts "#{d[:name]} (#{d[:type]})" }

Batch extraction

results = extractor.extract_batch(
  "model.lur",
  ["dia1", "dia2", "dia3"],
  output_dir: "diagrams/"
)

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

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ Extractor

Initialize extractor with options

Parameters:

  • options (Hash) (defaults to: {})

    Extraction options

Options Hash (options):

  • :padding (Integer)

    Padding around diagram

  • :background_color (String)

    Background color

  • :grid_visible (Boolean)

    Show grid lines

  • :interactive (Boolean)

    Enable interactivity

  • :config_path (String)

    Path to diagram config

  • :repository (#all_diagrams, #find_diagram, #classes_index, #packages_index, #associations_index, #document)

    Repository object for diagram extraction. If not provided, callers must supply repository to each method call.



54
55
56
57
# File 'lib/ea/diagram/extractor.rb', line 54

def initialize(options = {})
  @repository = options.delete(:repository)
  @options = resolve_options(options)
end

Instance Attribute Details

#optionsObject (readonly)

Returns the value of attribute options.



40
41
42
# File 'lib/ea/diagram/extractor.rb', line 40

def options
  @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

Parameters:

  • source (String, Object)

    LUR file path or repository object

  • diagram_ids (Array<String>)

    Array of diagram IDs

  • opts (Hash) (defaults to: {})

    Additional options

Options Hash (opts):

  • :output_dir (String)

    Output directory

Returns:

  • (Hash)

    Result with :success, :results, :summary



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.message}",
    error: e,
  }
end

#extract_one(source, diagram_id, opts = {}) ⇒ Hash

Extract and render a single diagram

Parameters:

  • source (String, Object)

    LUR file path or repository object

  • diagram_id (String)

    Diagram ID or name

  • opts (Hash) (defaults to: {})

    Additional options

Options Hash (opts):

  • :output (String)

    Output file path

Returns:

  • (Hash)

    Result with :success, :path, :diagram, :message



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.message}",
    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

Parameters:

  • source (String, Object) (defaults to: nil)

    LUR file path or repository object

Returns:

  • (Hash)

    Result with :success, :diagrams, :count, :message



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.message}",
    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 resolve_options(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

Parameters:

  • source (String, Object, nil)

    File path, repository, or nil

Returns:

  • (Object)

    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