lutaml-lml 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/lutaml/lml/cli.rb +84 -18
  3. data/lib/lutaml/lml/data_processor/value_processing.rb +6 -1
  4. data/lib/lutaml/lml/document_builder.rb +72 -3
  5. data/lib/lutaml/lml/format/adapter/mapping.rb +12 -0
  6. data/lib/lutaml/lml/format/adapter/standard_adapter.rb +41 -12
  7. data/lib/lutaml/lml/formatter/elk.rb +45 -0
  8. data/lib/lutaml/lml/formatter/graphviz/node_formatter.rb +2 -1
  9. data/lib/lutaml/lml/formatter/svg_pdf/path_parser.rb +134 -0
  10. data/lib/lutaml/lml/formatter/svg_pdf.rb +281 -0
  11. data/lib/lutaml/lml/formatter.rb +2 -0
  12. data/lib/lutaml/lml/grammar/lml.artifact.json +7265 -4550
  13. data/lib/lutaml/lml/grammar/lml.parg +230 -115
  14. data/lib/lutaml/lml/layout/elk_engine.rb +15 -0
  15. data/lib/lutaml/lml/layout/elk_graph_builder.rb +129 -0
  16. data/lib/lutaml/lml/layout/elk_svg_markers.rb +45 -0
  17. data/lib/lutaml/lml/layout/elk_svg_renderer.rb +119 -0
  18. data/lib/lutaml/lml/layout/graph_viz_engine.rb +1 -1
  19. data/lib/lutaml/lml/layout.rb +4 -0
  20. data/lib/lutaml/lml/model_compiler/enums.rb +172 -0
  21. data/lib/lutaml/lml/model_compiler/hydration.rb +123 -0
  22. data/lib/lutaml/lml/model_compiler/serialization_mappings.rb +162 -0
  23. data/lib/lutaml/lml/model_compiler/string_formats.rb +42 -0
  24. data/lib/lutaml/lml/model_compiler/validation.rb +65 -0
  25. data/lib/lutaml/lml/model_compiler.rb +46 -453
  26. data/lib/lutaml/lml/models/document.rb +29 -0
  27. data/lib/lutaml/lml/models/operation_parameter.rb +1 -0
  28. data/lib/lutaml/lml/models/reference.rb +23 -0
  29. data/lib/lutaml/lml/models/top_element_attribute.rb +12 -0
  30. data/lib/lutaml/lml/models/uml_class.rb +1 -1
  31. data/lib/lutaml/lml/preprocessor.rb +44 -79
  32. data/lib/lutaml/lml/transform.rb +54 -6
  33. data/lib/lutaml/lml/validator.rb +88 -19
  34. data/lib/lutaml/lml/version.rb +1 -1
  35. data/lib/lutaml/lml.rb +19 -3
  36. metadata +17 -4
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Formatter
5
+ # SVG-to-PDF renderer for ELK-layout diagrams (issue #53). Parses the
6
+ # ELK SVG output and re-emits it through pdfrb's Canvas: base-14
7
+ # standard fonts for text (metrics ship inside pdfrb, no external
8
+ # font resources), stroke/fill operators for shapes, and arrow
9
+ # markers drawn manually at polyline ends (SVG marker geometry is
10
+ # resolved from the document's <defs> and inlined with rotation).
11
+ class SvgPdf
12
+ autoload :PathParser, 'lutaml/lml/formatter/svg_pdf/path_parser'
13
+
14
+ FONT_VARIANTS = {
15
+ 'Helvetica' => { bold: :Helvetica_Bold, normal: :Helvetica },
16
+ 'Courier' => { bold: :Courier_Bold, normal: :Courier },
17
+ 'Times' => { bold: :Times_Bold, normal: :Times_Roman }
18
+ }.freeze
19
+ ROUNDING = 0.5523
20
+
21
+ def initialize(svg)
22
+ @svg = svg
23
+ end
24
+
25
+ def render
26
+ require 'pdfrb'
27
+ doc = Pdfrb::Document.new
28
+ page = doc.pages.add
29
+ @canvas = page.canvas
30
+ root = Moxml.parse(@svg).root
31
+ @width, @height = page_dimensions(root)
32
+ @canvas.line_width = 1
33
+ @markers = {}
34
+ @marker_parents = {}
35
+ collect_markers(root, nil)
36
+ walk(root, 0.0, 0.0)
37
+ io = StringIO.new
38
+ doc.write(io: io)
39
+ io.string.b
40
+ end
41
+
42
+ private
43
+
44
+ def page_dimensions(root)
45
+ [float_attr(root, 'width', 612.0), float_attr(root, 'height', 792.0)]
46
+ end
47
+
48
+ def float_attr(node, name, fallback)
49
+ node.attribute(name)&.value&.to_f || fallback
50
+ end
51
+
52
+ def collect_markers(node, _parent)
53
+ node.children.each do |child|
54
+ if child.name == 'marker'
55
+ shape = child.children.find { |c| %w[path polygon polyline].include?(c.name) }
56
+ if shape
57
+ @markers[child.attribute('id')&.value] = shape
58
+ @marker_parents[shape] = child
59
+ end
60
+ end
61
+ collect_markers(child, node)
62
+ end
63
+ end
64
+
65
+ # Depth-first walk applying cumulative translate(x,y).
66
+ def walk(node, dx, dy)
67
+ node.children.each do |child|
68
+ cdx, cdy = translate_of(child)
69
+ case child.name
70
+ when 'g'
71
+ walk(child, dx + cdx, dy + cdy)
72
+ when 'rect'
73
+ draw_rect(child, dx + cdx, dy + cdy)
74
+ when 'polyline'
75
+ draw_polyline(child, dx, dy)
76
+ when 'polygon'
77
+ draw_polygon(child, dx, dy)
78
+ when 'line'
79
+ draw_line(child, dx, dy)
80
+ when 'path'
81
+ draw_path(child, dx + cdx, dy + cdy)
82
+ when 'text'
83
+ draw_text(child, dx + cdx, dy + cdy)
84
+ end
85
+ end
86
+ end
87
+
88
+ def translate_of(node)
89
+ t = node.attribute('transform')&.value.to_s
90
+ m = t.match(/translate\(\s*([-\d.]+)[,\s]+([-\d.]+)\s*\)/)
91
+ m ? [m[1].to_f, m[2].to_f] : [0.0, 0.0]
92
+ end
93
+
94
+ # SVG user space is y-down; PDF is y-up.
95
+ def pt(x, y)
96
+ [x, @height - y]
97
+ end
98
+
99
+ def draw_rect(node, dx, dy)
100
+ x = float_attr(node, 'x', 0.0) + dx
101
+ y = float_attr(node, 'y', 0.0) + dy
102
+ w = float_attr(node, 'width', 0.0)
103
+ h = float_attr(node, 'height', 0.0)
104
+ rx = float_attr(node, 'rx', 0.0)
105
+ set_fill(node)
106
+ set_stroke(node)
107
+ if rx.positive?
108
+ rounded_path(x, y, w, h, rx)
109
+ else
110
+ @canvas.rectangle(*pt(x, y), w, h)
111
+ end
112
+ paint(node)
113
+ end
114
+
115
+ def rounded_path(x, y, w, h, r)
116
+ r = [r, w / 2.0, h / 2.0].min
117
+ k = ROUNDING * r
118
+ _, y2 = pt(x + w, y + h)
119
+ top_left, = pt(x, y)
120
+ @canvas.move_to(*pt(x + r, y))
121
+ @canvas.line_to(*pt(x + w - r, y))
122
+ @canvas.curve_to(x + w - r + k, y, x + w, y + r - k, x + w, y + r)
123
+ @canvas.line_to(x + w, y + h - r)
124
+ @canvas.curve_to(x + w, y + h - r + k, x + w - r + k, y + h, x + w - r, y2)
125
+ @canvas.line_to(x + r, y2)
126
+ @canvas.curve_to(x + r - k, y + h, x, y + h - r + k, x, y + h - r)
127
+ @canvas.line_to(x, y + r)
128
+ @canvas.curve_to(x, y + r - k, x + r - k, y, x + r, top_left)
129
+ end
130
+
131
+ def draw_polyline(node, dx, dy)
132
+ points = point_list(node, dx, dy)
133
+ return if points.empty?
134
+
135
+ set_stroke(node)
136
+ @canvas.line_width = float_attr(node, 'stroke-width', 1.0)
137
+ @canvas.polyline(points.map { |x, y| pt(x, y) })
138
+ @canvas.stroke
139
+ draw_marker(node, points)
140
+ end
141
+
142
+ def draw_polygon(node, dx, dy)
143
+ points = point_list(node, dx, dy)
144
+ return if points.empty?
145
+
146
+ set_fill(node)
147
+ set_stroke(node)
148
+ @canvas.polygon(points.map { |x, y| pt(x, y) })
149
+ paint(node)
150
+ end
151
+
152
+ def draw_line(node, dx, dy)
153
+ x1 = float_attr(node, 'x1', 0.0) + dx
154
+ y1 = float_attr(node, 'y1', 0.0) + dy
155
+ x2 = float_attr(node, 'x2', 0.0) + dx
156
+ y2 = float_attr(node, 'y2', 0.0) + dy
157
+ set_stroke(node)
158
+ @canvas.line(*pt(x1, y1), *pt(x2, y2))
159
+ end
160
+
161
+ def point_list(node, dx, dy)
162
+ node.attribute('points')&.value.to_s.scan(/(-?[\d.]+)[,\s]+(-?[\d.]+)/)
163
+ .map { |x, y| [x.to_f + dx, y.to_f + dy] }
164
+ end
165
+
166
+ def draw_path(node, dx, dy)
167
+ subpaths = PathParser.new(node.attribute('d')&.value.to_s, dx, dy).subpaths
168
+ set_fill(node)
169
+ set_stroke(node)
170
+ subpaths.each do |segments|
171
+ first = segments.first
172
+ next unless first
173
+
174
+ @canvas.move_to(*pt(*first.last_point))
175
+ segments.each do |segment|
176
+ case segment
177
+ when PathParser::Line
178
+ @canvas.line_to(*pt(*segment.point))
179
+ when PathParser::Curve
180
+ c1 = pt(*segment.c1)
181
+ c2 = pt(*segment.c2)
182
+ to = pt(*segment.point)
183
+ @canvas.curve_to(*c1, *c2, *to)
184
+ when PathParser::Close
185
+ @canvas.close_path
186
+ end
187
+ end
188
+ paint(node)
189
+ end
190
+ end
191
+
192
+ def draw_marker(node, points)
193
+ ref = node.attribute('marker-end')&.value.to_s[/url\(#([^)]+)\)/, 1]
194
+ shape = @markers[ref]
195
+ return unless shape && points.size >= 2
196
+
197
+ (x1, y1), (x2, y2) = points[-2, 2]
198
+ angle = Math.atan2(y2 - y1, x2 - x1)
199
+ cos = Math.cos(angle)
200
+ sin = Math.sin(angle)
201
+ marker = @marker_parents[shape]
202
+ ref_x = float_attr(marker, 'refX', 0.0)
203
+ ref_y = float_attr(marker, 'refY', 0.0)
204
+ set_fill(shape)
205
+ set_stroke(shape)
206
+ trace_marker(shape, x2, y2, cos, sin, ref_x, ref_y)
207
+ paint(shape)
208
+ end
209
+
210
+ # Trace the marker's shape geometry translated to (x2,y2) with the
211
+ # marker origin at (refX,refY), rotated by the edge direction.
212
+ def trace_marker(shape, ox, oy, cos, sin, ref_x, ref_y)
213
+ transform = lambda { |x, y|
214
+ lx = x - ref_x
215
+ ly = y - ref_y
216
+ [ox + lx * cos - ly * sin, oy + lx * sin + ly * cos]
217
+ }
218
+ d = shape.name == 'path' ? shape.attribute('d')&.value.to_s : nil
219
+ segments = d ? PathParser.new(d, 0.0, 0.0).subpaths.first : nil
220
+ return unless segments&.any?
221
+
222
+ first = segments.first.last_point
223
+ @canvas.move_to(*pt(*transform.call(*first)))
224
+ segments.each do |segment|
225
+ case segment
226
+ when PathParser::Line
227
+ @canvas.line_to(*pt(*transform.call(*segment.point)))
228
+ when PathParser::Curve
229
+ @canvas.curve_to(*pt(*transform.call(*segment.c1)),
230
+ *pt(*transform.call(*segment.c2)),
231
+ *pt(*transform.call(*segment.point)))
232
+ when PathParser::Close
233
+ @canvas.close_path
234
+ end
235
+ end
236
+ end
237
+
238
+ def draw_text(node, dx, dy)
239
+ x = float_attr(node, 'x', 0.0) + dx
240
+ y = float_attr(node, 'y', 0.0) + dy
241
+ size = float_attr(node, 'font-size', 12.0)
242
+ @canvas.text(node.text.to_s, at: pt(x, y),
243
+ font: font_for(node), size: size)
244
+ end
245
+
246
+ def font_for(node)
247
+ family = node.attribute('font-family')&.value.to_s
248
+ base = FONT_VARIANTS.keys.find { |k| family.include?(k) } || 'Helvetica'
249
+ bold = node.attribute('font-weight')&.value.to_s =~ /bold/i
250
+ FONT_VARIANTS[base][bold ? :bold : :normal]
251
+ end
252
+
253
+ def set_fill(node)
254
+ @fill_color = color_attr(node, 'fill')
255
+ @canvas.fill_color(@fill_color) if @fill_color
256
+ end
257
+
258
+ def set_stroke(node)
259
+ @stroke_color = color_attr(node, 'stroke')
260
+ @canvas.stroke_color(@stroke_color) if @stroke_color
261
+ end
262
+
263
+ def paint(node)
264
+ fill = color_attr(node, 'fill')
265
+ stroke = color_attr(node, 'stroke')
266
+ if fill && fill != 'none' && stroke && stroke != 'none'
267
+ @canvas.fill_stroke
268
+ elsif fill && fill != 'none'
269
+ @canvas.fill
270
+ else
271
+ @canvas.stroke
272
+ end
273
+ end
274
+
275
+ def color_attr(node, name)
276
+ value = node.attribute(name)&.value.to_s
277
+ value =~ /\A#/ ? value : nil
278
+ end
279
+ end
280
+ end
281
+ end
@@ -4,5 +4,7 @@ module Lutaml
4
4
  module Formatter
5
5
  autoload :Base, "lutaml/lml/formatter/base"
6
6
  autoload :Graphviz, "lutaml/lml/formatter/graphviz"
7
+ autoload :Elk, "lutaml/lml/formatter/elk"
8
+ autoload :SvgPdf, "lutaml/lml/formatter/svg_pdf"
7
9
  end
8
10
  end