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.
@@ -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
@@ -5,5 +5,6 @@ module Lutaml
5
5
  autoload :Base, "lutaml/lml/formatter/base"
6
6
  autoload :Graphviz, "lutaml/lml/formatter/graphviz"
7
7
  autoload :Elk, "lutaml/lml/formatter/elk"
8
+ autoload :SvgPdf, "lutaml/lml/formatter/svg_pdf"
8
9
  end
9
10
  end