clef 0.1.0 → 1.0.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -90
  3. data/Rakefile +21 -1
  4. data/exe/clef +21 -0
  5. data/lib/clef/compiler.rb +107 -4
  6. data/lib/clef/core/chord.rb +9 -3
  7. data/lib/clef/core/duration.rb +7 -3
  8. data/lib/clef/core/key_signature.rb +43 -36
  9. data/lib/clef/core/measure.rb +14 -10
  10. data/lib/clef/core/metadata.rb +52 -0
  11. data/lib/clef/core/note.rb +50 -4
  12. data/lib/clef/core/pitch.rb +73 -4
  13. data/lib/clef/core/rest.rb +11 -3
  14. data/lib/clef/core/score.rb +148 -9
  15. data/lib/clef/core/staff.rb +13 -3
  16. data/lib/clef/core/staff_group.rb +8 -2
  17. data/lib/clef/core/tempo.rb +5 -0
  18. data/lib/clef/core/tuplet.rb +48 -0
  19. data/lib/clef/core/validation.rb +39 -0
  20. data/lib/clef/core/voice.rb +21 -5
  21. data/lib/clef/engraving/font_manager.rb +1 -1
  22. data/lib/clef/engraving/glyph_table.rb +18 -3
  23. data/lib/clef/engraving/style.rb +41 -2
  24. data/lib/clef/ir/moment.rb +2 -2
  25. data/lib/clef/ir/music_tree.rb +2 -2
  26. data/lib/clef/ir/timeline.rb +25 -5
  27. data/lib/clef/layout/beam_layout.rb +2 -2
  28. data/lib/clef/layout/item.rb +26 -0
  29. data/lib/clef/layout/spacing.rb +6 -4
  30. data/lib/clef/layout/stem.rb +10 -6
  31. data/lib/clef/layout/system_layout.rb +71 -0
  32. data/lib/clef/midi/channel_map.rb +5 -2
  33. data/lib/clef/midi/exporter.rb +316 -38
  34. data/lib/clef/notation/dynamic.rb +5 -0
  35. data/lib/clef/notation/lyric.rb +33 -1
  36. data/lib/clef/parser/dsl.rb +249 -58
  37. data/lib/clef/parser/lilypond_lexer.rb +43 -3
  38. data/lib/clef/parser/lilypond_parser.rb +231 -17
  39. data/lib/clef/plugins/base.rb +24 -4
  40. data/lib/clef/plugins/registry.rb +80 -10
  41. data/lib/clef/renderer/base.rb +2 -2
  42. data/lib/clef/renderer/drawing_context.rb +26 -0
  43. data/lib/clef/renderer/notation_helpers.rb +92 -1
  44. data/lib/clef/renderer/pdf_renderer.rb +487 -82
  45. data/lib/clef/renderer/svg_renderer.rb +510 -97
  46. data/lib/clef/version.rb +1 -1
  47. data/lib/clef.rb +60 -7
  48. data/sig/clef.rbs +292 -0
  49. metadata +14 -5
@@ -8,27 +8,45 @@ module Clef
8
8
  include NotationHelpers
9
9
 
10
10
  WIDTH = 1024
11
- HEIGHT = 512
11
+ LEFT_PADDING = 60
12
+ RIGHT_PADDING = 60
13
+ STAFF_START_X = 140
14
+ STAFF_TOP = 90
12
15
 
13
16
  # @param score [Clef::Core::Score]
14
17
  # @param path [String]
15
- # @param _positions [Hash]
16
- def render(score, path, _positions: nil, **_options)
18
+ # @param positions [Hash]
19
+ # @param _layout [Hash, nil]
20
+ def render(score, path, positions: nil, layout: nil, **_options)
21
+ height = svg_height(score, layout)
17
22
  document = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
18
- xml.svg(xmlns: "http://www.w3.org/2000/svg", width: WIDTH, height: HEIGHT) do
19
- draw_score(xml, score)
23
+ xml.svg(xmlns: "http://www.w3.org/2000/svg",
24
+ width: WIDTH,
25
+ height: height,
26
+ viewBox: "0 0 #{WIDTH} #{height}") do
27
+ draw_score(xml, score, positions: positions, layout: layout)
20
28
  end
21
29
  end
22
- File.write(path, document.to_xml)
30
+ write_output(path, document.to_xml)
23
31
  end
24
32
 
25
33
  # @param xml [Nokogiri::XML::Builder]
26
34
  # @param score [Clef::Core::Score]
27
- def draw_score(xml, score)
28
- score.staves.each_with_index do |staff, index|
29
- baseline = 80 + (index * style.staff_space * 9)
30
- draw_staff_lines(xml, baseline)
31
- draw_notes(xml, staff, baseline)
35
+ # @param positions [Hash, nil]
36
+ # @param layout [Hash, nil]
37
+ def draw_score(xml, score, positions: nil, layout: nil)
38
+ return draw_systems(xml, score, layout) if layout&.dig(:systems)&.any?
39
+
40
+ draw_header(xml, score)
41
+ staff_index = 0
42
+ score.staff_groups.each do |group|
43
+ group_start = STAFF_TOP + (staff_index * style.staff_gap)
44
+ group.staves.each do |staff|
45
+ baseline = STAFF_TOP + (staff_index * style.staff_gap)
46
+ draw_staff(xml, staff, baseline, positions: positions, layout: layout)
47
+ staff_index += 1
48
+ end
49
+ draw_staff_group(xml, group, group_start, STAFF_TOP + ((staff_index - 1) * style.staff_gap)) if group.staves.length > 1
32
50
  end
33
51
  end
34
52
 
@@ -37,24 +55,8 @@ module Clef
37
55
  def draw_staff_lines(xml, baseline)
38
56
  5.times do |line|
39
57
  y = baseline + (line * style.staff_space)
40
- xml.line(x1: 60, y1: y, x2: WIDTH - 60, y2: y, stroke: "black", "stroke-width": 1)
41
- end
42
- end
43
-
44
- # @param xml [Nokogiri::XML::Builder]
45
- # @param staff [Clef::Core::Staff]
46
- # @param baseline [Float]
47
- def draw_notes(xml, staff, baseline)
48
- x = 140
49
- staff.measures.each do |measure|
50
- voice = measure.voices.values.first
51
- next unless voice
52
-
53
- voice.elements.each do |element|
54
- draw_element(xml, element, x, baseline, staff.clef)
55
- x += duration_spacing(element)
56
- end
57
- x += 16
58
+ xml.line(x1: LEFT_PADDING, y1: y, x2: WIDTH - RIGHT_PADDING, y2: y,
59
+ stroke: "black", "stroke-width": 1, class: "staff-line")
58
60
  end
59
61
  end
60
62
 
@@ -65,134 +67,429 @@ module Clef
65
67
  # @param clef [Clef::Core::Clef]
66
68
  def draw_element(xml, element, x, baseline, clef)
67
69
  case element
68
- when Clef::Core::Note then draw_note(xml, element, x, baseline, clef)
70
+ when Clef::Core::Note then draw_note(xml, element, x, baseline, clef, accidental_state: {}, key_signature: nil)
69
71
  when Clef::Core::Rest then draw_rest(xml, element, x, baseline)
70
- when Clef::Core::Chord then draw_chord(xml, element, x, baseline, clef)
72
+ when Clef::Core::Chord then draw_chord(xml, element, x, baseline, clef, accidental_state: {}, key_signature: nil)
73
+ when Clef::Core::Tuplet then draw_tuplet(xml, element, x, baseline, clef, accidental_state: {}, key_signature: nil)
74
+ when Clef::Notation::Dynamic then draw_dynamic(xml, element, x, baseline)
75
+ when Clef::Core::Tempo then draw_tempo_change(xml, element, x, baseline)
71
76
  end
72
77
  end
73
78
 
74
79
  private
75
80
 
76
- # @param xml [Nokogiri::XML::Builder]
77
- # @param note [Clef::Core::Note]
78
- # @param x [Float]
79
- # @param baseline [Float]
80
- # @param clef [Clef::Core::Clef]
81
- def draw_note(xml, note, x, baseline, clef)
81
+ def draw_header(xml, score)
82
+ draw_text(xml, score.title, x: LEFT_PADDING, y: 30, class: "title", "font-size": 18, fill: "black") if score.title
83
+ if score.composer
84
+ draw_text(xml, score.composer, x: WIDTH - RIGHT_PADDING, y: 30, class: "composer",
85
+ "font-size": 12, fill: "black", "text-anchor": "end")
86
+ end
87
+ return unless score.tempo
88
+
89
+ draw_text(xml, tempo_text(score.tempo), x: LEFT_PADDING, y: 52, class: "tempo", "font-size": 11, fill: "black")
90
+ end
91
+
92
+ def draw_staff(xml, staff, baseline, positions:, layout:)
93
+ draw_staff_lines(xml, baseline)
94
+ cursor = draw_clef(xml, staff.clef, LEFT_PADDING + 8, baseline)
95
+ cursor = draw_key_signature(xml, staff.key_signature, cursor + style.staff_space, baseline)
96
+ cursor = draw_time_signature(xml, staff.time_signature, cursor + style.staff_space, baseline)
97
+ note_points = draw_measures(xml, staff, [cursor + style.measure_padding, STAFF_START_X].max, baseline, positions, layout)
98
+ draw_lyrics(xml, staff, note_points, baseline)
99
+ end
100
+
101
+ def draw_systems(xml, score, layout)
102
+ draw_header(xml, score)
103
+ @system_page_height = svg_page_height_for(score)
104
+ layout[:systems].each do |system|
105
+ score.staff_groups.each do |group|
106
+ baselines = group.staves.map { |staff| svg_system_baseline(system, staff) }
107
+ group.staves.each do |staff|
108
+ draw_staff_system(xml, staff, baselines[group.staves.index(staff)], system, layout)
109
+ end
110
+ draw_staff_group(xml, group, baselines.first, baselines.last) if group.staves.length > 1
111
+ end
112
+ draw_layout_items(xml, layout, system)
113
+ end
114
+ end
115
+
116
+ def draw_staff_system(xml, staff, baseline, system, layout)
117
+ draw_staff_lines(xml, baseline)
118
+ cursor = draw_clef(xml, staff.clef, LEFT_PADDING + 8, baseline)
119
+ cursor = draw_key_signature(xml, staff.key_signature, cursor + style.staff_space, baseline)
120
+ cursor = draw_time_signature(xml, staff.time_signature, cursor + style.staff_space, baseline)
121
+ note_points = draw_measures(xml, staff, [cursor + style.measure_padding, STAFF_START_X].max,
122
+ baseline, layout[:positions], layout, system: system)
123
+ draw_lyrics(xml, staff, note_points, baseline)
124
+ end
125
+
126
+ def draw_staff_group(xml, group, first_baseline, last_baseline)
127
+ x = LEFT_PADDING - 24
128
+ case group.bracket_type
129
+ when :brace
130
+ draw_text(xml, "{", x: x, y: first_baseline + style.staff_space * 3.5,
131
+ class: "staff-group brace", "font-size": (last_baseline - first_baseline + 50), fill: "black")
132
+ when :bracket
133
+ xml.path(d: "M #{x + 8} #{first_baseline} L #{x} #{first_baseline} L #{x} #{last_baseline + (style.staff_space * 4)} L #{x + 8} #{last_baseline + (style.staff_space * 4)}",
134
+ fill: "none", stroke: "black", "stroke-width": 2, class: "staff-group bracket")
135
+ end
136
+ end
137
+
138
+ def draw_measures(xml, staff, start_x, baseline, positions, layout, system: nil)
139
+ note_points = {}
140
+ voice_elements = Hash.new { |hash, key| hash[key] = [] }
141
+ measure_start = Clef::Ir::Moment.new(0)
142
+ staff.measures.each do |measure|
143
+ measure_points, measure_voice_elements = draw_measure(xml, measure, staff, start_x, baseline, measure_start, positions, layout, system: system)
144
+ note_points.merge!(measure_points)
145
+ measure_voice_elements.each { |voice_id, elements| voice_elements[voice_id].concat(elements) }
146
+ bar_moment = measure_start + measure_length_for(measure)
147
+ if system.nil? || system.include_moment?(bar_moment)
148
+ bar_x = x_for_moment(positions, bar_moment, start_x, position_offset: system&.position_offset)
149
+ draw_barline(xml, bar_x, baseline)
150
+ end
151
+ measure_start += measure_length_for(measure)
152
+ end
153
+ voice_elements.each_value { |elements| draw_note_connections(xml, elements, note_points) }
154
+ note_points
155
+ end
156
+
157
+ def draw_measure(xml, measure, staff, start_x, baseline, measure_start, positions, layout, system: nil)
158
+ note_points = {}
159
+ voice_elements = {}
160
+ accidental_state = {}
161
+ measure.voices.each_with_index do |(voice_id, voice), index|
162
+ voice_elements[voice_id] = voice.elements
163
+ voice_offset = voice_vertical_offset(index)
164
+ cursor = Clef::Ir::Moment.new(measure_start.value)
165
+ voice.elements.each do |element|
166
+ if system.nil? || system.include_moment?(cursor)
167
+ x = x_for_moment(positions, cursor, start_x, position_offset: system&.position_offset)
168
+ draw_element_with_context(xml, element, x, baseline + voice_offset, staff, measure, accidental_state, note_points)
169
+ end
170
+ cursor += element.length
171
+ end
172
+ draw_beams(xml, layout, staff, measure, voice_id, note_points)
173
+ end
174
+ [note_points, voice_elements]
175
+ end
176
+
177
+ def draw_element_with_context(xml, element, x, baseline, staff, measure, accidental_state, note_points)
178
+ case element
179
+ when Clef::Core::Note
180
+ draw_note(xml, element, x, baseline, staff.clef,
181
+ accidental_state: accidental_state,
182
+ key_signature: measure.key_signature || staff.key_signature)
183
+ note_points[element.object_id] = [x, pitch_y(element.pitch, baseline, staff.clef)]
184
+ when Clef::Core::Rest
185
+ draw_rest(xml, element, x, baseline)
186
+ when Clef::Core::Chord
187
+ draw_chord(xml, element, x, baseline, staff.clef,
188
+ accidental_state: accidental_state,
189
+ key_signature: measure.key_signature || staff.key_signature)
190
+ note_points[element.object_id] = [x, baseline]
191
+ when Clef::Core::Tuplet
192
+ draw_tuplet(xml, element, x, baseline, staff.clef,
193
+ accidental_state: accidental_state,
194
+ key_signature: measure.key_signature || staff.key_signature,
195
+ note_points: note_points)
196
+ when Clef::Notation::Dynamic
197
+ draw_dynamic(xml, element, x, baseline)
198
+ when Clef::Core::Tempo
199
+ draw_tempo_change(xml, element, x, baseline)
200
+ end
201
+ end
202
+
203
+ def draw_note(xml, note, x, baseline, clef, accidental_state:, key_signature:)
82
204
  y = pitch_y(note.pitch, baseline, clef)
205
+ draw_ledger_lines(xml, x, y, baseline)
83
206
  draw_notehead(xml, x, y, duration: note.duration)
84
- draw_accidental(xml, note.pitch, x, y)
207
+ draw_accidental(xml, note.pitch, x, y, key_signature, accidental_state)
85
208
  draw_stem(xml, note, x, y, clef) if stem_required?(note.duration)
209
+ draw_flag(xml, note, x, y, clef) if flag_required?(note.duration)
86
210
  draw_dot(xml, note.duration, x, y)
87
211
  draw_articulations(xml, note.articulations, x, y)
88
212
  end
89
213
 
90
- # @param xml [Nokogiri::XML::Builder]
91
- # @param rest [Clef::Core::Rest]
92
- # @param x [Float]
93
- # @param baseline [Float]
94
214
  def draw_rest(xml, rest, x, baseline)
95
- if rest.duration.base == :whole
96
- xml.rect(x: x - 4, y: baseline + (style.staff_space * 2.8), width: 8, height: 3, fill: "black")
215
+ return if rest.kind == :invisible || rest.kind == :spacer
216
+
217
+ y = rest_y(rest, baseline)
218
+ if rest.kind == :multi_measure
219
+ draw_multi_measure_rest(xml, rest, x, baseline)
220
+ elsif rest.duration.base == :whole
221
+ xml.rect(x: x - 4, y: y, width: 8, height: 3, fill: "black", class: "rest rest-whole")
222
+ elsif rest.duration.base == :half
223
+ xml.rect(x: x - 4, y: y - style.staff_space, width: 8, height: 3, fill: "black", class: "rest rest-half")
97
224
  else
98
- draw_text(xml, "r", x: x - 4, y: baseline + (style.staff_space * 2.1), fill: "black", "font-size": 14)
225
+ draw_shaped_rest(xml, rest, x, y)
99
226
  end
100
227
  end
101
228
 
102
- # @param xml [Nokogiri::XML::Builder]
103
- # @param chord [Clef::Core::Chord]
104
- # @param x [Float]
105
- # @param baseline [Float]
106
- # @param clef [Clef::Core::Clef]
107
- def draw_chord(xml, chord, x, baseline, clef)
229
+ def draw_dynamic(xml, dynamic, x, baseline)
230
+ draw_text(xml, dynamic_text(dynamic), x: x, y: baseline + (style.staff_space * 6),
231
+ class: "dynamic", "font-size": 11, fill: "black", "font-style": "italic")
232
+ end
233
+
234
+ def draw_tempo_change(xml, tempo, x, baseline)
235
+ draw_text(xml, tempo_text(tempo), x: x, y: baseline - (style.staff_space * 1.5),
236
+ class: "tempo-change", "font-size": 10, fill: "black")
237
+ end
238
+
239
+ def draw_chord(xml, chord, x, baseline, clef, accidental_state:, key_signature:)
108
240
  notes = chord_notes(chord)
241
+ offsets = chord_note_offsets(notes)
109
242
  ys = []
110
- notes.each do |note|
243
+ notes.each_with_index do |note, index|
111
244
  y = pitch_y(note.pitch, baseline, clef)
112
245
  ys << y
113
- draw_notehead(xml, x, y, duration: chord.duration)
114
- draw_accidental(xml, note.pitch, x, y)
246
+ note_x = x + offsets[index]
247
+ draw_ledger_lines(xml, note_x, y, baseline)
248
+ draw_notehead(xml, note_x, y, duration: chord.duration)
249
+ draw_accidental(xml, note.pitch, note_x - accidental_offset(index), y, key_signature, accidental_state)
115
250
  end
116
251
  draw_chord_stem(xml, notes, x, ys, clef) if stem_required?(chord.duration)
117
- draw_dot(xml, chord.duration, x, ys.sum / ys.length.to_f)
252
+ ys.each { |y| draw_dot(xml, chord.duration, x, y) }
253
+ end
254
+
255
+ def draw_tuplet(xml, tuplet, x, baseline, clef, accidental_state:, key_signature:, note_points: {})
256
+ cursor = x
257
+ tuplet.elements.each do |element|
258
+ draw_element_with_context(xml, element, cursor, baseline,
259
+ Struct.new(:clef, :key_signature).new(clef, key_signature),
260
+ Struct.new(:key_signature).new(key_signature),
261
+ accidental_state,
262
+ note_points)
263
+ cursor += duration_spacing(element) * tuplet.ratio
264
+ end
265
+ draw_text(xml, tuplet.actual.to_s, x: x + ((cursor - x) / 2.0), y: baseline - style.staff_space,
266
+ class: "tuplet", "font-size": 10, fill: "black", "text-anchor": "middle")
118
267
  end
119
268
 
120
- # @param xml [Nokogiri::XML::Builder]
121
- # @param x [Float]
122
- # @param y [Float]
123
- # @param duration [Clef::Core::Duration]
124
269
  def draw_notehead(xml, x, y, duration:)
125
270
  if filled_notehead?(duration)
126
- xml.circle(cx: x, cy: y, r: 3, fill: "black")
271
+ xml.circle(cx: x, cy: y, r: style.notehead_width / 2.0, fill: "black", class: "notehead filled")
127
272
  else
128
- xml.ellipse(cx: x, cy: y, rx: 3.5, ry: 2.5, fill: "white", stroke: "black", "stroke-width": 1)
273
+ xml.ellipse(cx: x, cy: y, rx: style.notehead_width / 2.0, ry: 2.5,
274
+ fill: "white", stroke: "black", "stroke-width": 1, class: "notehead hollow")
129
275
  end
130
276
  end
131
277
 
132
- # @param xml [Nokogiri::XML::Builder]
133
- # @param note [Clef::Core::Note]
134
- # @param x [Float]
135
- # @param y [Float]
136
- # @param clef [Clef::Core::Clef]
137
278
  def draw_stem(xml, note, x, y, clef)
138
279
  direction = Clef::Layout::Stem.direction(note, clef)
139
280
  stem_len = style.staff_space * Clef::Layout::Stem.length(note, clef, direction)
140
- y2 = direction == :up ? y - stem_len : y + stem_len
141
- stem_x = direction == :up ? x + 3 : x - 3
142
- xml.line(x1: stem_x, y1: y, x2: stem_x, y2: y2, stroke: "black", "stroke-width": 1)
281
+ y2 = (direction == :up) ? y - stem_len : y + stem_len
282
+ stem_x = (direction == :up) ? x + 3 : x - 3
283
+ xml.line(x1: stem_x, y1: y, x2: stem_x, y2: y2, stroke: "black", "stroke-width": 1, class: "stem")
143
284
  end
144
285
 
145
- # @param xml [Nokogiri::XML::Builder]
146
- # @param notes [Array<Clef::Core::Note>]
147
- # @param x [Float]
148
- # @param ys [Array<Float>]
149
- # @param clef [Clef::Core::Clef]
150
286
  def draw_chord_stem(xml, notes, x, ys, clef)
151
287
  direction = Clef::Layout::Stem.direction(notes, clef)
152
288
  anchor_note = chord_stem_anchor_note(notes, direction)
153
289
  anchor_index = notes.index(anchor_note)
154
290
  anchor_y = ys[anchor_index]
155
291
  stem_len = style.staff_space * chord_stem_length(notes, clef, direction)
156
- y2 = direction == :up ? anchor_y - stem_len : anchor_y + stem_len
157
- stem_x = direction == :up ? x + 3 : x - 3
158
- xml.line(x1: stem_x, y1: anchor_y, x2: stem_x, y2: y2, stroke: "black", "stroke-width": 1)
292
+ y2 = (direction == :up) ? anchor_y - stem_len : anchor_y + stem_len
293
+ stem_x = (direction == :up) ? x + 3 : x - 3
294
+ xml.line(x1: stem_x, y1: anchor_y, x2: stem_x, y2: y2, stroke: "black", "stroke-width": 1, class: "stem")
159
295
  end
160
296
 
161
- # @param xml [Nokogiri::XML::Builder]
162
- # @param pitch [Clef::Core::Pitch]
163
- # @param x [Float]
164
- # @param y [Float]
165
- def draw_accidental(xml, pitch, x, y)
166
- key = accidental_glyph_key(pitch.alteration)
167
- return unless key
297
+ def draw_flag(xml, note, x, y, clef)
298
+ direction = Clef::Layout::Stem.direction(note, clef)
299
+ stem_len = style.staff_space * Clef::Layout::Stem.length(note, clef, direction)
300
+ stem_x = (direction == :up) ? x + 3 : x - 3
301
+ stem_y = (direction == :up) ? y - stem_len : y + stem_len
302
+ sweep = (direction == :up) ? 10 : -10
303
+ xml.path(d: "M #{stem_x} #{stem_y} q 12 #{sweep} 4 #{sweep * 2}",
304
+ fill: "none", stroke: "black", "stroke-width": 1, class: "flag")
305
+ end
168
306
 
169
- draw_text(xml, accidental_text(pitch.alteration), x: x - 12, y: y + 3, fill: "black", "font-size": 9)
307
+ def draw_accidental(xml, pitch, x, y, key_signature, state)
308
+ alteration = accidental_for_pitch(pitch, key_signature, state)
309
+ return if alteration.nil?
310
+
311
+ draw_text(xml, accidental_text(alteration), x: x - 12, y: y + 3, fill: "black", "font-size": 9, class: "accidental")
170
312
  end
171
313
 
172
- # @param xml [Nokogiri::XML::Builder]
173
- # @param duration [Clef::Core::Duration]
174
- # @param x [Float]
175
- # @param y [Float]
176
314
  def draw_dot(xml, duration, x, y)
177
315
  return if duration.dots.zero?
178
316
 
179
317
  duration.dots.times do |index|
180
- xml.circle(cx: x + 8 + (index * 3), cy: y, r: 1, fill: "black")
318
+ xml.circle(cx: x + 8 + (index * 3), cy: y, r: 1, fill: "black", class: "dot")
181
319
  end
182
320
  end
183
321
 
184
- # @param xml [Nokogiri::XML::Builder]
185
- # @param articulations [Array<Symbol>]
186
- # @param x [Float]
187
- # @param y [Float]
188
322
  def draw_articulations(xml, articulations, x, y)
189
- return if articulations.empty?
323
+ articulations.each do |articulation|
324
+ case articulation
325
+ when :staccato
326
+ xml.circle(cx: x, cy: y - 12, r: 1.5, fill: "black", class: "articulation staccato")
327
+ when :tenuto
328
+ xml.line(x1: x - 4, y1: y - 12, x2: x + 4, y2: y - 12, stroke: "black",
329
+ "stroke-width": 1, class: "articulation tenuto")
330
+ when :accent
331
+ draw_text(xml, ">", x: x - 4, y: y - 10, fill: "black", "font-size": 9, class: "articulation accent")
332
+ when :marcato
333
+ xml.path(d: "M #{x - 4} #{y - 10} L #{x} #{y - 15} L #{x + 4} #{y - 10}",
334
+ fill: "none", stroke: "black", "stroke-width": 1, class: "articulation marcato")
335
+ when :fermata
336
+ xml.path(d: "M #{x - 7} #{y - 13} Q #{x} #{y - 20} #{x + 7} #{y - 13}",
337
+ fill: "none", stroke: "black", "stroke-width": 1, class: "articulation fermata")
338
+ xml.circle(cx: x, cy: y - 13, r: 1.2, fill: "black", class: "articulation fermata-dot")
339
+ else
340
+ draw_text(xml, articulation.to_s, x: x - 4, y: y - 12, fill: "black", "font-size": 6, class: "articulation")
341
+ end
342
+ end
343
+ end
344
+
345
+ def draw_multi_measure_rest(xml, rest, x, baseline)
346
+ y = baseline + (style.staff_space * 2)
347
+ xml.line(x1: x - 12, y1: y, x2: x + 12, y2: y, stroke: "black", "stroke-width": 4,
348
+ class: "rest rest-multi-measure")
349
+ draw_text(xml, rest.measures.to_s, x: x, y: baseline - style.staff_space,
350
+ fill: "black", "font-size": 10, "text-anchor": "middle", class: "rest-count")
351
+ end
190
352
 
191
- draw_text(xml, articulations.join(","), x: x - 4, y: y - 12, fill: "black", "font-size": 6)
353
+ def draw_shaped_rest(xml, rest, x, y)
354
+ case rest.duration.base
355
+ when :quarter
356
+ xml.path(d: "M #{x} #{y - 12} c -6 5 8 8 0 14 c 6 3 -5 7 0 13",
357
+ fill: "none", stroke: "black", "stroke-width": 2, class: "rest rest-quarter")
358
+ when :eighth, :sixteenth
359
+ xml.path(d: "M #{x} #{y - 12} q 10 4 1 12 l -6 14",
360
+ fill: "none", stroke: "black", "stroke-width": 2, class: "rest rest-#{rest.duration.base}")
361
+ xml.path(d: "M #{x + 1} #{y - 4} q 8 4 0 9",
362
+ fill: "none", stroke: "black", "stroke-width": 1, class: "rest rest-#{rest.duration.base}-flag")
363
+ if rest.duration.base == :sixteenth
364
+ xml.path(d: "M #{x - 1} #{y + 2} q 8 4 0 9",
365
+ fill: "none", stroke: "black", "stroke-width": 1, class: "rest rest-sixteenth-flag")
366
+ end
367
+ else
368
+ draw_text(xml, rest_label(rest.duration), x: x - 4, y: y, fill: "black", "font-size": 14,
369
+ class: "rest rest-#{rest.duration.base}")
370
+ end
371
+ end
372
+
373
+ def draw_beams(xml, layout, staff, measure, voice_id, note_points)
374
+ Array(layout&.dig(:beams, staff.id, measure.number, voice_id)).each do |group|
375
+ points = group.filter_map { |note| note_points[note.object_id] }
376
+ next if points.length < 2
377
+
378
+ y = points.map(&:last).min - (style.staff_space * 3.5)
379
+ xml.line(x1: points.first.first + 3, y1: y, x2: points.last.first + 3, y2: y,
380
+ stroke: "black", "stroke-width": style.beam_thickness, class: "beam")
381
+ end
382
+ end
383
+
384
+ def draw_note_connections(xml, elements, note_points)
385
+ notes = flatten_elements(elements).select { |element| element.is_a?(Clef::Core::Note) }
386
+ notes.each_with_index do |note, index|
387
+ draw_connection_to_next(xml, note, notes[(index + 1)..], note_points, "tie") if note.tie_state == :start
388
+ draw_connection_to_next(xml, note, notes[(index + 1)..], note_points, "slur") if note.slur_start
389
+ end
390
+ end
391
+
392
+ def draw_connection_to_next(xml, note, candidates, note_points, class_name)
393
+ target = (class_name == "tie") ? candidates&.find { |candidate| candidate.pitch.enharmonic?(note.pitch) } : candidates&.find(&:slur_end)
394
+ return unless target
395
+
396
+ start_point = note_points[note.object_id]
397
+ end_point = note_points[target.object_id]
398
+ return unless start_point && end_point
399
+
400
+ lift = (class_name == "tie") ? 8 : 14
401
+ path = "M #{start_point[0] + 5} #{start_point[1] - 5} C #{start_point[0] + 18} #{start_point[1] - lift}, " \
402
+ "#{end_point[0] - 18} #{end_point[1] - lift}, #{end_point[0] - 5} #{end_point[1] - 5}"
403
+ xml.path(d: path, fill: "none", stroke: "black", "stroke-width": 1, class: class_name)
404
+ end
405
+
406
+ def draw_lyrics(xml, staff, note_points, baseline)
407
+ Array(staff.metadata[:lyrics]).each do |lyric|
408
+ elements = staff.measures.flat_map { |measure| Array(measure.voices[lyric.voice_id]&.elements) }
409
+ .then { |elements| lyric_elements(elements) }
410
+ lyric_events(lyric, elements).each do |event|
411
+ draw_lyric_event(xml, event, note_points, baseline)
412
+ end
413
+ end
414
+ end
415
+
416
+ def draw_lyric_event(xml, event, note_points, baseline)
417
+ y = baseline + (style.staff_space * 6.5)
418
+ case event[:type]
419
+ when :text
420
+ point = note_points[event[:element]&.object_id]
421
+ return unless point
422
+
423
+ draw_text(xml, event[:syllable], x: point.first, y: y,
424
+ fill: "black", "font-size": 10, "text-anchor": "middle", class: "lyric")
425
+ when :hyphen
426
+ from = note_points[event[:from]&.object_id]
427
+ to = note_points[event[:to]&.object_id]
428
+ return unless from && to
429
+
430
+ draw_text(xml, "-", x: (from.first + to.first) / 2.0, y: y,
431
+ fill: "black", "font-size": 10, "text-anchor": "middle", class: "lyric-hyphen")
432
+ when :extender
433
+ from = note_points[event[:from]&.object_id]
434
+ to = note_points[event[:to]&.object_id]
435
+ return unless from && to
436
+
437
+ xml.line(x1: from.first + 8, y1: y - 4, x2: to.first - 8, y2: y - 4,
438
+ stroke: "black", "stroke-width": 1, class: "lyric-extender")
439
+ end
440
+ end
441
+
442
+ def draw_ledger_lines(xml, x, y, baseline)
443
+ top = baseline
444
+ bottom = baseline + (style.staff_space * 4)
445
+ return if y.between?(top, bottom)
446
+
447
+ start_line = (y < top) ? y : bottom + style.staff_space
448
+ end_line = (y < top) ? top - style.staff_space : y
449
+ current = nearest_staff_line(start_line)
450
+ while current <= end_line
451
+ xml.line(x1: x - 6, y1: current, x2: x + 6, y2: current,
452
+ stroke: "black", "stroke-width": 1, class: "ledger-line")
453
+ current += style.staff_space
454
+ end
455
+ end
456
+
457
+ def draw_clef(xml, clef, x, baseline)
458
+ draw_text(xml, fallback_clef_text(clef), x: x, y: baseline + (style.staff_space * 3.2),
459
+ class: "clef", "font-size": 22, fill: "black")
460
+ x + 24
461
+ end
462
+
463
+ def draw_key_signature(xml, key_signature, x, baseline)
464
+ return x unless key_signature
465
+
466
+ accidentals = key_signature.accidentals
467
+ order = (accidentals[:type] == :sharp) ? NotationHelpers::SHARP_ORDER : NotationHelpers::FLAT_ORDER
468
+ text = (accidentals[:type] == :sharp) ? "#" : "b"
469
+ order.first(accidentals[:count].to_i).each_with_index do |note_name, index|
470
+ draw_text(xml, text, x: x + (index * 8), y: baseline + key_signature_y(note_name),
471
+ class: "key-signature", "font-size": 12, fill: "black")
472
+ end
473
+ x + (accidentals[:count].to_i * 8)
474
+ end
475
+
476
+ def draw_time_signature(xml, time_signature, x, baseline)
477
+ return x unless time_signature
478
+
479
+ draw_text(xml, time_signature.numerator.to_s, x: x, y: baseline + (style.staff_space * 1.6),
480
+ class: "time-signature numerator", "font-size": 11, fill: "black", "text-anchor": "middle")
481
+ draw_text(xml, time_signature.denominator.to_s, x: x, y: baseline + (style.staff_space * 3.3),
482
+ class: "time-signature denominator", "font-size": 11, fill: "black", "text-anchor": "middle")
483
+ x + 14
484
+ end
485
+
486
+ def draw_barline(xml, x, baseline)
487
+ xml.line(x1: x, y1: baseline, x2: x, y2: baseline + (style.staff_space * 4),
488
+ stroke: "black", "stroke-width": 1, class: "barline")
192
489
  end
193
490
 
194
491
  def pitch_y(pitch, baseline, clef)
195
- calculate_pitch_y(pitch, baseline, clef, vertical_axis: -1)
492
+ calculate_pitch_y(pitch, baseline, clef, vertical_axis: drawing_context.vertical_axis)
196
493
  end
197
494
 
198
495
  def draw_text(xml, content, **attributes)
@@ -201,6 +498,122 @@ module Clef
201
498
  node.content = content.to_s
202
499
  xml.parent << node
203
500
  end
501
+
502
+ def x_for_moment(positions, moment, start_x, position_offset: 0.0)
503
+ return start_x unless positions
504
+
505
+ start_x + positions.fetch(moment, position_offset.to_f) - position_offset.to_f
506
+ end
507
+
508
+ def measure_length_for(measure)
509
+ return measure.time_signature.measure_length if measure.time_signature
510
+
511
+ measure.voices.values.map(&:total_length).max || Rational(0, 1)
512
+ end
513
+
514
+ def voice_vertical_offset(index)
515
+ return 0 if index.zero?
516
+
517
+ index.odd? ? style.staff_space * 0.8 : -style.staff_space * 0.8
518
+ end
519
+
520
+ def rest_y(rest, baseline)
521
+ offset = {
522
+ whole: 2.8,
523
+ half: 2.8,
524
+ quarter: 2.3,
525
+ eighth: 2.0,
526
+ sixteenth: 2.0
527
+ }.fetch(rest.duration.base, 2.0)
528
+ baseline + (style.staff_space * offset)
529
+ end
530
+
531
+ def fallback_clef_text(clef)
532
+ {
533
+ treble: "G",
534
+ bass: "F",
535
+ alto: "C",
536
+ tenor: "C",
537
+ percussion: "||",
538
+ tab: "TAB"
539
+ }.fetch(clef.type, clef.type.to_s[0].upcase)
540
+ end
541
+
542
+ def key_signature_y(note_name)
543
+ {
544
+ f: style.staff_space * 0.9,
545
+ c: style.staff_space * 2.4,
546
+ g: style.staff_space * 0.5,
547
+ d: style.staff_space * 2.0,
548
+ a: style.staff_space * 3.4,
549
+ e: style.staff_space * 1.5,
550
+ b: style.staff_space * 3.0
551
+ }.fetch(note_name)
552
+ end
553
+
554
+ def chord_note_offsets(notes)
555
+ notes.each_with_index.map do |note, index|
556
+ previous = notes[index - 1]
557
+ (previous && (diatonic_step(note.pitch) - diatonic_step(previous.pitch)).abs == 1) ? 6 : 0
558
+ end
559
+ end
560
+
561
+ def accidental_offset(index)
562
+ index * 4
563
+ end
564
+
565
+ def nearest_staff_line(value)
566
+ (value / style.staff_space).floor * style.staff_space
567
+ end
568
+
569
+ def tempo_text(tempo)
570
+ "#{tempo.beat_unit.to_lilypond} = #{tempo.bpm}"
571
+ end
572
+
573
+ def svg_height(score, layout)
574
+ systems = Array(layout&.dig(:systems))
575
+ return [STAFF_TOP + (score.staves.length * style.staff_gap) + style.staff_gap, 180].max if systems.empty?
576
+
577
+ page_count = systems.map(&:page_index).max.to_i + 1
578
+ [STAFF_TOP + (page_count * svg_page_height_for(score)) + style.system_gap, 180].max
579
+ end
580
+
581
+ def svg_page_height_for(score)
582
+ STAFF_TOP + (score.staves.length * style.staff_gap) + (style.system_gap * 2)
583
+ end
584
+
585
+ def svg_system_baseline(system, staff)
586
+ STAFF_TOP + (system.page_index * @system_page_height.to_f) + system.line_top + system.staff_offset(staff.id)
587
+ end
588
+
589
+ def draw_layout_items(xml, layout, system)
590
+ Array(layout[:items]).each do |item|
591
+ next unless item.type == :text
592
+ next unless system.include_moment?(item.moment)
593
+
594
+ x = x_for_moment(layout[:positions], item.moment, STAFF_START_X, position_offset: system.position_offset)
595
+ y = STAFF_TOP + (system.page_index * @system_page_height.to_f) + system.line_top - style.staff_space
596
+ draw_text(xml, item.payload.fetch(:text), x: x, y: y, class: "layout-item", "font-size": 10, fill: "black")
597
+ end
598
+ end
599
+
600
+ def drawing_context
601
+ @drawing_context ||= Clef::Renderer::DrawingContext.svg
602
+ end
603
+
604
+ def write_output(target, content)
605
+ return target.write(content) if target.respond_to?(:write)
606
+
607
+ ensure_parent_directory!(target)
608
+ File.write(target, content)
609
+ end
610
+
611
+ def ensure_parent_directory!(path)
612
+ parent = File.dirname(path.to_s)
613
+ return if parent.nil? || parent == "." || Dir.exist?(parent)
614
+
615
+ raise ArgumentError, "output directory does not exist: #{parent}"
616
+ end
204
617
  end
205
618
  end
206
619
  end