lutaml-lml 0.2.1 → 0.3.1
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.
- checksums.yaml +4 -4
- data/lib/lutaml/lml/cli.rb +76 -17
- data/lib/lutaml/lml/data_processor/attribute_processing.rb +9 -1
- data/lib/lutaml/lml/data_processor/value_processing.rb +6 -1
- data/lib/lutaml/lml/format/adapter/mapping.rb +12 -0
- data/lib/lutaml/lml/format/adapter/standard_adapter.rb +35 -8
- data/lib/lutaml/lml/formatter/elk.rb +5 -2
- data/lib/lutaml/lml/formatter/svg_pdf/path_parser.rb +134 -0
- data/lib/lutaml/lml/formatter/svg_pdf.rb +285 -0
- data/lib/lutaml/lml/formatter.rb +1 -0
- data/lib/lutaml/lml/grammar/lml.artifact.json +2505 -2234
- data/lib/lutaml/lml/grammar/lml.parg +25 -9
- data/lib/lutaml/lml/model_compiler/enums.rb +172 -0
- data/lib/lutaml/lml/model_compiler/hydration.rb +123 -0
- data/lib/lutaml/lml/model_compiler/serialization_mappings.rb +162 -0
- data/lib/lutaml/lml/model_compiler/string_formats.rb +42 -0
- data/lib/lutaml/lml/model_compiler/validation.rb +65 -0
- data/lib/lutaml/lml/model_compiler.rb +46 -474
- data/lib/lutaml/lml/transform.rb +53 -5
- data/lib/lutaml/lml/validator.rb +2 -1
- data/lib/lutaml/lml/version.rb +1 -1
- data/lib/lutaml/lml.rb +18 -3
- metadata +9 -2
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
# Whitespace between elements arrives as text nodes under
|
|
69
|
+
# some moxml adapters; only elements carry geometry.
|
|
70
|
+
next unless child.element?
|
|
71
|
+
|
|
72
|
+
cdx, cdy = translate_of(child)
|
|
73
|
+
case child.name
|
|
74
|
+
when 'g'
|
|
75
|
+
walk(child, dx + cdx, dy + cdy)
|
|
76
|
+
when 'rect'
|
|
77
|
+
draw_rect(child, dx + cdx, dy + cdy)
|
|
78
|
+
when 'polyline'
|
|
79
|
+
draw_polyline(child, dx, dy)
|
|
80
|
+
when 'polygon'
|
|
81
|
+
draw_polygon(child, dx, dy)
|
|
82
|
+
when 'line'
|
|
83
|
+
draw_line(child, dx, dy)
|
|
84
|
+
when 'path'
|
|
85
|
+
draw_path(child, dx + cdx, dy + cdy)
|
|
86
|
+
when 'text'
|
|
87
|
+
draw_text(child, dx + cdx, dy + cdy)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def translate_of(node)
|
|
93
|
+
t = node.attribute('transform')&.value.to_s
|
|
94
|
+
m = t.match(/translate\(\s*([-\d.]+)[,\s]+([-\d.]+)\s*\)/)
|
|
95
|
+
m ? [m[1].to_f, m[2].to_f] : [0.0, 0.0]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# SVG user space is y-down; PDF is y-up.
|
|
99
|
+
def pt(x, y)
|
|
100
|
+
[x, @height - y]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def draw_rect(node, dx, dy)
|
|
104
|
+
x = float_attr(node, 'x', 0.0) + dx
|
|
105
|
+
y = float_attr(node, 'y', 0.0) + dy
|
|
106
|
+
w = float_attr(node, 'width', 0.0)
|
|
107
|
+
h = float_attr(node, 'height', 0.0)
|
|
108
|
+
rx = float_attr(node, 'rx', 0.0)
|
|
109
|
+
set_fill(node)
|
|
110
|
+
set_stroke(node)
|
|
111
|
+
if rx.positive?
|
|
112
|
+
rounded_path(x, y, w, h, rx)
|
|
113
|
+
else
|
|
114
|
+
@canvas.rectangle(*pt(x, y), w, h)
|
|
115
|
+
end
|
|
116
|
+
paint(node)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def rounded_path(x, y, w, h, r)
|
|
120
|
+
r = [r, w / 2.0, h / 2.0].min
|
|
121
|
+
k = ROUNDING * r
|
|
122
|
+
_, y2 = pt(x + w, y + h)
|
|
123
|
+
top_left, = pt(x, y)
|
|
124
|
+
@canvas.move_to(*pt(x + r, y))
|
|
125
|
+
@canvas.line_to(*pt(x + w - r, y))
|
|
126
|
+
@canvas.curve_to(x + w - r + k, y, x + w, y + r - k, x + w, y + r)
|
|
127
|
+
@canvas.line_to(x + w, y + h - r)
|
|
128
|
+
@canvas.curve_to(x + w, y + h - r + k, x + w - r + k, y + h, x + w - r, y2)
|
|
129
|
+
@canvas.line_to(x + r, y2)
|
|
130
|
+
@canvas.curve_to(x + r - k, y + h, x, y + h - r + k, x, y + h - r)
|
|
131
|
+
@canvas.line_to(x, y + r)
|
|
132
|
+
@canvas.curve_to(x, y + r - k, x + r - k, y, x + r, top_left)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def draw_polyline(node, dx, dy)
|
|
136
|
+
points = point_list(node, dx, dy)
|
|
137
|
+
return if points.empty?
|
|
138
|
+
|
|
139
|
+
set_stroke(node)
|
|
140
|
+
@canvas.line_width = float_attr(node, 'stroke-width', 1.0)
|
|
141
|
+
@canvas.polyline(points.map { |x, y| pt(x, y) })
|
|
142
|
+
@canvas.stroke
|
|
143
|
+
draw_marker(node, points)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def draw_polygon(node, dx, dy)
|
|
147
|
+
points = point_list(node, dx, dy)
|
|
148
|
+
return if points.empty?
|
|
149
|
+
|
|
150
|
+
set_fill(node)
|
|
151
|
+
set_stroke(node)
|
|
152
|
+
@canvas.polygon(points.map { |x, y| pt(x, y) })
|
|
153
|
+
paint(node)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def draw_line(node, dx, dy)
|
|
157
|
+
x1 = float_attr(node, 'x1', 0.0) + dx
|
|
158
|
+
y1 = float_attr(node, 'y1', 0.0) + dy
|
|
159
|
+
x2 = float_attr(node, 'x2', 0.0) + dx
|
|
160
|
+
y2 = float_attr(node, 'y2', 0.0) + dy
|
|
161
|
+
set_stroke(node)
|
|
162
|
+
@canvas.line(*pt(x1, y1), *pt(x2, y2))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def point_list(node, dx, dy)
|
|
166
|
+
node.attribute('points')&.value.to_s.scan(/(-?[\d.]+)[,\s]+(-?[\d.]+)/)
|
|
167
|
+
.map { |x, y| [x.to_f + dx, y.to_f + dy] }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def draw_path(node, dx, dy)
|
|
171
|
+
subpaths = PathParser.new(node.attribute('d')&.value.to_s, dx, dy).subpaths
|
|
172
|
+
set_fill(node)
|
|
173
|
+
set_stroke(node)
|
|
174
|
+
subpaths.each do |segments|
|
|
175
|
+
first = segments.first
|
|
176
|
+
next unless first
|
|
177
|
+
|
|
178
|
+
@canvas.move_to(*pt(*first.last_point))
|
|
179
|
+
segments.each do |segment|
|
|
180
|
+
case segment
|
|
181
|
+
when PathParser::Line
|
|
182
|
+
@canvas.line_to(*pt(*segment.point))
|
|
183
|
+
when PathParser::Curve
|
|
184
|
+
c1 = pt(*segment.c1)
|
|
185
|
+
c2 = pt(*segment.c2)
|
|
186
|
+
to = pt(*segment.point)
|
|
187
|
+
@canvas.curve_to(*c1, *c2, *to)
|
|
188
|
+
when PathParser::Close
|
|
189
|
+
@canvas.close_path
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
paint(node)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def draw_marker(node, points)
|
|
197
|
+
ref = node.attribute('marker-end')&.value.to_s[/url\(#([^)]+)\)/, 1]
|
|
198
|
+
shape = @markers[ref]
|
|
199
|
+
return unless shape && points.size >= 2
|
|
200
|
+
|
|
201
|
+
(x1, y1), (x2, y2) = points[-2, 2]
|
|
202
|
+
angle = Math.atan2(y2 - y1, x2 - x1)
|
|
203
|
+
cos = Math.cos(angle)
|
|
204
|
+
sin = Math.sin(angle)
|
|
205
|
+
marker = @marker_parents[shape]
|
|
206
|
+
ref_x = float_attr(marker, 'refX', 0.0)
|
|
207
|
+
ref_y = float_attr(marker, 'refY', 0.0)
|
|
208
|
+
set_fill(shape)
|
|
209
|
+
set_stroke(shape)
|
|
210
|
+
trace_marker(shape, x2, y2, cos, sin, ref_x, ref_y)
|
|
211
|
+
paint(shape)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Trace the marker's shape geometry translated to (x2,y2) with the
|
|
215
|
+
# marker origin at (refX,refY), rotated by the edge direction.
|
|
216
|
+
def trace_marker(shape, ox, oy, cos, sin, ref_x, ref_y)
|
|
217
|
+
transform = lambda { |x, y|
|
|
218
|
+
lx = x - ref_x
|
|
219
|
+
ly = y - ref_y
|
|
220
|
+
[ox + lx * cos - ly * sin, oy + lx * sin + ly * cos]
|
|
221
|
+
}
|
|
222
|
+
d = shape.name == 'path' ? shape.attribute('d')&.value.to_s : nil
|
|
223
|
+
segments = d ? PathParser.new(d, 0.0, 0.0).subpaths.first : nil
|
|
224
|
+
return unless segments&.any?
|
|
225
|
+
|
|
226
|
+
first = segments.first.last_point
|
|
227
|
+
@canvas.move_to(*pt(*transform.call(*first)))
|
|
228
|
+
segments.each do |segment|
|
|
229
|
+
case segment
|
|
230
|
+
when PathParser::Line
|
|
231
|
+
@canvas.line_to(*pt(*transform.call(*segment.point)))
|
|
232
|
+
when PathParser::Curve
|
|
233
|
+
@canvas.curve_to(*pt(*transform.call(*segment.c1)),
|
|
234
|
+
*pt(*transform.call(*segment.c2)),
|
|
235
|
+
*pt(*transform.call(*segment.point)))
|
|
236
|
+
when PathParser::Close
|
|
237
|
+
@canvas.close_path
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def draw_text(node, dx, dy)
|
|
243
|
+
x = float_attr(node, 'x', 0.0) + dx
|
|
244
|
+
y = float_attr(node, 'y', 0.0) + dy
|
|
245
|
+
size = float_attr(node, 'font-size', 12.0)
|
|
246
|
+
@canvas.text(node.text.to_s, at: pt(x, y),
|
|
247
|
+
font: font_for(node), size: size)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def font_for(node)
|
|
251
|
+
family = node.attribute('font-family')&.value.to_s
|
|
252
|
+
base = FONT_VARIANTS.keys.find { |k| family.include?(k) } || 'Helvetica'
|
|
253
|
+
bold = node.attribute('font-weight')&.value.to_s =~ /bold/i
|
|
254
|
+
FONT_VARIANTS[base][bold ? :bold : :normal]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def set_fill(node)
|
|
258
|
+
@fill_color = color_attr(node, 'fill')
|
|
259
|
+
@canvas.fill_color(@fill_color) if @fill_color
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def set_stroke(node)
|
|
263
|
+
@stroke_color = color_attr(node, 'stroke')
|
|
264
|
+
@canvas.stroke_color(@stroke_color) if @stroke_color
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def paint(node)
|
|
268
|
+
fill = color_attr(node, 'fill')
|
|
269
|
+
stroke = color_attr(node, 'stroke')
|
|
270
|
+
if fill && fill != 'none' && stroke && stroke != 'none'
|
|
271
|
+
@canvas.fill_stroke
|
|
272
|
+
elsif fill && fill != 'none'
|
|
273
|
+
@canvas.fill
|
|
274
|
+
else
|
|
275
|
+
@canvas.stroke
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def color_attr(node, name)
|
|
280
|
+
value = node.attribute(name)&.value.to_s
|
|
281
|
+
value =~ /\A#/ ? value : nil
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|