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,38 +8,55 @@ module Clef
8
8
  include NotationHelpers
9
9
 
10
10
  LEFT_PADDING = 80
11
- TOP_PADDING = 40
11
+ TOP_PADDING = 56
12
+ STAFF_START_X = 160
12
13
 
13
14
  # @param score [Clef::Core::Score]
14
15
  # @param path [String]
15
16
  # @param positions [Hash]
16
- def render(score, path, positions: nil, **_options)
17
+ # @param layout [Hash, nil]
18
+ def render(score, path, positions: nil, layout: nil, **_options)
19
+ return render_to_io(score, path, positions: positions, layout: layout) if path.respond_to?(:write)
20
+
21
+ ensure_parent_directory!(path)
17
22
  Prawn::Document.generate(path, page_size: style.page_size, margin: style.margin) do |pdf|
18
23
  prepare_canvas(pdf)
19
- draw_score(pdf, score, positions)
24
+ draw_score(pdf, score, positions, layout: layout)
20
25
  end
21
26
  end
22
27
 
23
28
  # @param pdf [Prawn::Document]
24
29
  # @param score [Clef::Core::Score]
25
- # @param _positions [Hash, nil]
26
- def draw_score(pdf, score, _positions)
27
- score.staves.each_with_index do |staff, index|
28
- baseline = pdf.cursor - TOP_PADDING - (index * style.staff_space * 10)
29
- draw_staff(pdf, staff, baseline)
30
+ # @param positions [Hash, nil]
31
+ # @param layout [Hash, nil]
32
+ def draw_score(pdf, score, positions, layout: nil)
33
+ return draw_systems(pdf, score, layout) if layout&.dig(:systems)&.any?
34
+
35
+ draw_header(pdf, score)
36
+ staff_index = 0
37
+ score.staff_groups.each do |group|
38
+ group_start = pdf.cursor - TOP_PADDING - (staff_index * style.staff_gap)
39
+ group.staves.each do |staff|
40
+ baseline = pdf.cursor - TOP_PADDING - (staff_index * style.staff_gap)
41
+ draw_staff(pdf, staff, baseline, positions: positions, layout: layout)
42
+ staff_index += 1
43
+ end
44
+ draw_staff_group(pdf, group, group_start, staff_index - 1) if group.staves.length > 1
30
45
  end
31
46
  end
32
47
 
33
48
  # @param pdf [Prawn::Document]
34
49
  # @param staff [Clef::Core::Staff]
35
50
  # @param baseline [Float]
36
- def draw_staff(pdf, staff, baseline)
51
+ # @param positions [Hash, nil]
52
+ # @param layout [Hash, nil]
53
+ def draw_staff(pdf, staff, baseline, positions: nil, layout: nil)
37
54
  draw_staff_lines(pdf, baseline)
38
55
  cursor = draw_clef(pdf, staff.clef, LEFT_PADDING - 24, baseline)
39
- cursor += style.staff_space
40
- cursor = draw_metadata(pdf, staff, cursor, baseline)
41
- cursor += style.staff_space
42
- draw_measures(pdf, staff, [cursor, LEFT_PADDING + (style.staff_space * 6)].max, baseline)
56
+ cursor = draw_key_signature(pdf, staff.key_signature, cursor + style.staff_space, baseline)
57
+ cursor = draw_time_signature(pdf, staff.time_signature, cursor + style.staff_space, baseline)
58
+ note_points = draw_measures(pdf, staff, [cursor + style.measure_padding, STAFF_START_X].max, baseline, positions, layout)
59
+ draw_lyrics(pdf, staff, note_points, baseline)
43
60
  end
44
61
 
45
62
  # @param pdf [Prawn::Document]
@@ -57,6 +74,7 @@ module Clef
57
74
  # @param baseline [Float]
58
75
  def draw_clef(pdf, clef, x, baseline)
59
76
  glyph = smufl_enabled? ? glyph_table[:"clef_#{clef.type}"] : fallback_clef_text(clef)
77
+ glyph ||= fallback_clef_text(clef)
60
78
  size = smufl_enabled? ? 16 : 10
61
79
  y = smufl_enabled? ? (baseline - style.staff_space) : (baseline + (style.staff_space * 1.5))
62
80
 
@@ -69,43 +87,62 @@ module Clef
69
87
  # @param x [Float]
70
88
  # @param baseline [Float]
71
89
  def draw_metadata(pdf, staff, x, baseline)
72
- text = metadata_text(staff)
73
- return x if text.nil?
74
-
75
- size = smufl_enabled? ? 11 : 9
76
- y = smufl_enabled? ? (baseline - style.staff_space) : (baseline + (style.staff_space * 1.5))
77
- pdf.text_box(text, at: [x, y], size: size)
78
- x + text_width(pdf, text, size: size)
90
+ cursor = draw_key_signature(pdf, staff.key_signature, x, baseline)
91
+ draw_time_signature(pdf, staff.time_signature, cursor + style.staff_space, baseline)
79
92
  end
80
93
 
81
94
  # @param pdf [Prawn::Document]
82
95
  # @param staff [Clef::Core::Staff]
83
96
  # @param start_x [Float]
84
97
  # @param baseline [Float]
85
- def draw_measures(pdf, staff, start_x, baseline)
86
- cursor = start_x
98
+ # @param positions [Hash, nil]
99
+ # @param layout [Hash, nil]
100
+ def draw_measures(pdf, staff, start_x, baseline, positions = nil, layout = nil, system: nil)
101
+ note_points = {}
102
+ voice_elements = Hash.new { |hash, key| hash[key] = [] }
103
+ measure_start = Clef::Ir::Moment.new(0)
87
104
  staff.measures.each do |measure|
88
- cursor = draw_measure(pdf, measure, staff.clef, cursor, baseline)
89
- draw_barline(pdf, cursor, baseline)
90
- cursor += style.staff_space * 1.2
105
+ measure_points, measure_voice_elements = draw_measure(pdf, measure, staff, start_x, baseline, measure_start, positions, layout, system: system)
106
+ note_points.merge!(measure_points)
107
+ measure_voice_elements.each { |voice_id, elements| voice_elements[voice_id].concat(elements) }
108
+ bar_moment = measure_start + measure_length_for(measure)
109
+ if system.nil? || system.include_moment?(bar_moment)
110
+ bar_x = x_for_moment(positions, bar_moment, start_x, position_offset: system&.position_offset)
111
+ draw_barline(pdf, bar_x, baseline)
112
+ end
113
+ measure_start += measure_length_for(measure)
91
114
  end
115
+ voice_elements.each_value { |elements| draw_note_connections(pdf, elements, note_points) }
116
+ note_points
92
117
  end
93
118
 
94
119
  # @param pdf [Prawn::Document]
95
120
  # @param measure [Clef::Core::Measure]
96
- # @param clef [Clef::Core::Clef]
97
- # @param cursor [Float]
121
+ # @param staff [Clef::Core::Staff]
122
+ # @param start_x [Float]
98
123
  # @param baseline [Float]
99
- # @return [Float]
100
- def draw_measure(pdf, measure, clef, cursor, baseline)
101
- voice = measure.voices.values.first
102
- return cursor unless voice
103
-
104
- voice.elements.each do |element|
105
- draw_element(pdf, element, cursor, baseline, clef)
106
- cursor += duration_spacing(element)
124
+ # @param measure_start [Clef::Ir::Moment]
125
+ # @param positions [Hash, nil]
126
+ # @param layout [Hash, nil]
127
+ # @return [Hash]
128
+ def draw_measure(pdf, measure, staff, start_x, baseline, measure_start = Clef::Ir::Moment.new(0), positions = nil, layout = nil, system: nil)
129
+ note_points = {}
130
+ voice_elements = {}
131
+ accidental_state = {}
132
+ measure.voices.each_with_index do |(voice_id, voice), index|
133
+ voice_elements[voice_id] = voice.elements
134
+ cursor = Clef::Ir::Moment.new(measure_start.value)
135
+ voice_baseline = baseline + voice_vertical_offset(index)
136
+ voice.elements.each do |element|
137
+ if system.nil? || system.include_moment?(cursor)
138
+ x = x_for_moment(positions, cursor, start_x, position_offset: system&.position_offset)
139
+ draw_element_with_context(pdf, element, x, voice_baseline, staff, measure, accidental_state, note_points)
140
+ end
141
+ cursor += element.length
142
+ end
143
+ draw_beams(pdf, layout, staff, measure, voice_id, note_points)
107
144
  end
108
- cursor
145
+ [note_points, voice_elements]
109
146
  end
110
147
 
111
148
  # @param pdf [Prawn::Document]
@@ -115,9 +152,12 @@ module Clef
115
152
  # @param clef [Clef::Core::Clef]
116
153
  def draw_element(pdf, element, x, baseline, clef)
117
154
  case element
118
- when Clef::Core::Note then draw_note(pdf, element, x, baseline, clef)
155
+ when Clef::Core::Note then draw_note(pdf, element, x, baseline, clef, accidental_state: {}, key_signature: nil)
119
156
  when Clef::Core::Rest then draw_rest(pdf, element, x, baseline)
120
- when Clef::Core::Chord then draw_chord(pdf, element, x, baseline, clef)
157
+ when Clef::Core::Chord then draw_chord(pdf, element, x, baseline, clef, accidental_state: {}, key_signature: nil)
158
+ when Clef::Core::Tuplet then draw_tuplet(pdf, element, x, baseline, clef, accidental_state: {}, key_signature: nil)
159
+ when Clef::Notation::Dynamic then draw_dynamic(pdf, element, x, baseline)
160
+ when Clef::Core::Tempo then draw_tempo_change(pdf, element, x, baseline)
121
161
  end
122
162
  end
123
163
 
@@ -126,11 +166,13 @@ module Clef
126
166
  # @param x [Float]
127
167
  # @param baseline [Float]
128
168
  # @param clef [Clef::Core::Clef]
129
- def draw_note(pdf, note, x, baseline, clef)
169
+ def draw_note(pdf, note, x, baseline, clef, accidental_state:, key_signature:)
130
170
  y = pitch_to_y(note.pitch, baseline, clef)
171
+ draw_ledger_lines(pdf, x, y, baseline)
131
172
  draw_notehead(pdf, x, y, duration: note.duration)
132
- draw_accidental(pdf, note.pitch, x, y)
173
+ draw_accidental(pdf, note.pitch, x, y, key_signature, accidental_state)
133
174
  draw_stem(pdf, note, x, y, clef) if stem_required?(note.duration)
175
+ draw_flag(pdf, note, x, y, clef) if flag_required?(note.duration)
134
176
  draw_dot(pdf, note.duration, x, y)
135
177
  draw_articulations(pdf, note.articulations, x, y)
136
178
  end
@@ -140,13 +182,17 @@ module Clef
140
182
  # @param x [Float]
141
183
  # @param baseline [Float]
142
184
  def draw_rest(pdf, rest, x, baseline)
185
+ return if rest.kind == :invisible || rest.kind == :spacer
186
+
143
187
  glyph = if smufl_enabled?
144
- key = rest.duration.base == :whole ? :rest_whole : :rest_quarter
145
- glyph_table[key]
146
- else
147
- "r"
148
- end
149
- pdf.text_box(glyph, at: [x, baseline - (style.staff_space * 1.5)], size: 14)
188
+ glyph_table[rest_glyph_key(rest.duration)] || glyph_table[:rest_quarter]
189
+ else
190
+ rest_label(rest.duration)
191
+ end
192
+ pdf.text_box(glyph, at: [x, rest_y(rest, baseline)], size: 14)
193
+ return unless rest.kind == :multi_measure && rest.measures > 1
194
+
195
+ pdf.text_box(rest.measures.to_s, at: [x - 2, baseline + style.staff_space], size: 8)
150
196
  end
151
197
 
152
198
  # @param pdf [Prawn::Document]
@@ -154,17 +200,36 @@ module Clef
154
200
  # @param x [Float]
155
201
  # @param baseline [Float]
156
202
  # @param clef [Clef::Core::Clef]
157
- def draw_chord(pdf, chord, x, baseline, clef)
203
+ def draw_chord(pdf, chord, x, baseline, clef, accidental_state:, key_signature:)
158
204
  notes = chord_notes(chord)
205
+ offsets = chord_note_offsets(notes)
159
206
  ys = []
160
- notes.each do |note|
207
+ notes.each_with_index do |note, index|
161
208
  y = pitch_to_y(note.pitch, baseline, clef)
162
209
  ys << y
163
- draw_notehead(pdf, x, y, duration: chord.duration)
164
- draw_accidental(pdf, note.pitch, x, y)
210
+ note_x = x + offsets[index]
211
+ draw_ledger_lines(pdf, note_x, y, baseline)
212
+ draw_notehead(pdf, note_x, y, duration: chord.duration)
213
+ draw_accidental(pdf, note.pitch, note_x - accidental_offset(index), y, key_signature, accidental_state)
165
214
  end
166
215
  draw_chord_stem(pdf, notes, x, ys, clef) if stem_required?(chord.duration)
167
- draw_dot(pdf, chord.duration, x, ys.sum / ys.length.to_f)
216
+ ys.each { |y| draw_dot(pdf, chord.duration, x, y) }
217
+ end
218
+
219
+ # @param pdf [Prawn::Document]
220
+ # @param tuplet [Clef::Core::Tuplet]
221
+ # @param x [Float]
222
+ # @param baseline [Float]
223
+ # @param clef [Clef::Core::Clef]
224
+ def draw_tuplet(pdf, tuplet, x, baseline, clef, accidental_state:, key_signature:, note_points: {})
225
+ cursor = x
226
+ staff_context = Struct.new(:clef, :key_signature).new(clef, key_signature)
227
+ measure_context = Struct.new(:key_signature).new(key_signature)
228
+ tuplet.elements.each do |element|
229
+ draw_element_with_context(pdf, element, cursor, baseline, staff_context, measure_context, accidental_state, note_points)
230
+ cursor += duration_spacing(element) * tuplet.ratio
231
+ end
232
+ pdf.text_box(tuplet.actual.to_s, at: [x + ((cursor - x) / 2.0), baseline + style.staff_space], size: 8)
168
233
  end
169
234
 
170
235
  # @param pdf [Prawn::Document]
@@ -174,11 +239,11 @@ module Clef
174
239
  def draw_notehead(pdf, x, y, duration:)
175
240
  pdf.fill_color("000000")
176
241
  if filled_notehead?(duration)
177
- pdf.circle([x, y], 3)
242
+ pdf.circle([x, y], style.notehead_width / 2.0)
178
243
  pdf.fill
179
244
  else
180
245
  pdf.fill_color("FFFFFF")
181
- pdf.ellipse([x, y], 3.5, 2.5)
246
+ pdf.ellipse([x, y], style.notehead_width / 2.0, 2.5)
182
247
  pdf.fill_and_stroke
183
248
  pdf.fill_color("000000")
184
249
  end
@@ -192,8 +257,8 @@ module Clef
192
257
  def draw_stem(pdf, note, x, y, clef)
193
258
  direction = Clef::Layout::Stem.direction(note, clef)
194
259
  stem_len = style.staff_space * Clef::Layout::Stem.length(note, clef, direction)
195
- y2 = direction == :up ? y + stem_len : y - stem_len
196
- stem_x = direction == :up ? x + 3 : x - 3
260
+ y2 = (direction == :up) ? y + stem_len : y - stem_len
261
+ stem_x = (direction == :up) ? x + 3 : x - 3
197
262
  pdf.stroke_line [stem_x, y], [stem_x, y2]
198
263
  end
199
264
 
@@ -208,8 +273,8 @@ module Clef
208
273
  anchor_index = notes.index(anchor_note)
209
274
  anchor_y = ys[anchor_index]
210
275
  stem_len = style.staff_space * chord_stem_length(notes, clef, direction)
211
- y2 = direction == :up ? anchor_y + stem_len : anchor_y - stem_len
212
- stem_x = direction == :up ? x + 3 : x - 3
276
+ y2 = (direction == :up) ? anchor_y + stem_len : anchor_y - stem_len
277
+ stem_x = (direction == :up) ? x + 3 : x - 3
213
278
  pdf.stroke_line [stem_x, anchor_y], [stem_x, y2]
214
279
  end
215
280
 
@@ -217,11 +282,14 @@ module Clef
217
282
  # @param pitch [Clef::Core::Pitch]
218
283
  # @param x [Float]
219
284
  # @param y [Float]
220
- def draw_accidental(pdf, pitch, x, y)
221
- key = accidental_glyph_key(pitch.alteration)
222
- return unless key
223
-
224
- glyph = smufl_enabled? ? glyph_table[key] : accidental_text(pitch.alteration)
285
+ # @param key_signature [Clef::Core::KeySignature, nil]
286
+ # @param state [Hash]
287
+ def draw_accidental(pdf, pitch, x, y, key_signature, state)
288
+ alteration = accidental_for_pitch(pitch, key_signature, state)
289
+ return if alteration.nil?
290
+
291
+ key = accidental_glyph_key(alteration)
292
+ glyph = smufl_enabled? ? glyph_table[key] : accidental_text(alteration)
225
293
  pdf.text_box(glyph, at: [x - 12, y + 5], size: 9)
226
294
  end
227
295
 
@@ -243,10 +311,36 @@ module Clef
243
311
  # @param x [Float]
244
312
  # @param y [Float]
245
313
  def draw_articulations(pdf, articulations, x, y)
246
- return if articulations.empty?
314
+ articulations.each do |articulation|
315
+ case articulation
316
+ when :staccato
317
+ pdf.circle([x, y + 12], 1.5)
318
+ pdf.fill
319
+ when :tenuto
320
+ pdf.stroke_line [x - 4, y + 12], [x + 4, y + 12]
321
+ when :accent
322
+ pdf.text_box(">", at: [x - 4, y + 14], size: 9)
323
+ when :marcato
324
+ pdf.stroke_line [x - 4, y + 10], [x, y + 16]
325
+ pdf.stroke_line [x, y + 16], [x + 4, y + 10]
326
+ when :fermata
327
+ draw_fermata(pdf, x, y)
328
+ else
329
+ pdf.text_box(articulation.to_s, at: [x - 4, y + 12], size: 6)
330
+ end
331
+ end
332
+ end
247
333
 
248
- text = articulations.join(",")
249
- pdf.text_box(text, at: [x - 4, y + 12], size: 6)
334
+ def draw_fermata(pdf, x, y)
335
+ if pdf.respond_to?(:stroke_curve)
336
+ pdf.stroke_curve [x - 7, y + 12], [x + 7, y + 12],
337
+ bounds: [[x - 5, y + 18], [x + 5, y + 18]]
338
+ else
339
+ pdf.stroke_line [x - 7, y + 12], [x, y + 17]
340
+ pdf.stroke_line [x, y + 17], [x + 7, y + 12]
341
+ end
342
+ pdf.circle([x, y + 12], 1.2)
343
+ pdf.fill
250
344
  end
251
345
 
252
346
  # @param pdf [Prawn::Document]
@@ -258,29 +352,243 @@ module Clef
258
352
  pdf.stroke_line [x, top], [x, bottom]
259
353
  end
260
354
 
261
- # @param _pdf [Prawn::Document]
262
- # @param _slurs [Array]
263
- def draw_slurs(_pdf, _slurs = []); end
355
+ # @param pdf [Prawn::Document]
356
+ # @param slurs [Array<Clef::Notation::Slur>]
357
+ # @param note_points [Hash]
358
+ def draw_slurs(pdf, slurs = [], note_points: {})
359
+ slurs.each { |slur| draw_notation_connection(pdf, slur, note_points, lift: 14) }
360
+ end
264
361
 
265
- # @param _pdf [Prawn::Document]
266
- # @param _ties [Array]
267
- def draw_ties(_pdf, _ties = []); end
362
+ # @param pdf [Prawn::Document]
363
+ # @param ties [Array<Clef::Notation::Tie>]
364
+ # @param note_points [Hash]
365
+ def draw_ties(pdf, ties = [], note_points: {})
366
+ ties.each { |tie| draw_notation_connection(pdf, tie, note_points, lift: 8) }
367
+ end
268
368
 
269
- # @param _pdf [Prawn::Document]
270
- # @param _beams [Array]
271
- def draw_beams(_pdf, _beams = []); end
369
+ private
272
370
 
273
- # @param _pdf [Prawn::Document]
274
- # @param _lyrics [Array]
275
- def draw_lyrics(_pdf, _lyrics = []); end
371
+ def draw_systems(pdf, score, layout)
372
+ draw_header(pdf, score)
373
+ current_page = 0
374
+ layout[:systems].each_with_index do |system, index|
375
+ if index.positive? && system.page_index != current_page
376
+ pdf.start_new_page
377
+ current_page = system.page_index
378
+ draw_header(pdf, score)
379
+ end
380
+ score.staff_groups.each do |group|
381
+ baselines = group.staves.map { |staff| pdf_system_baseline(pdf, system, staff) }
382
+ group.staves.each do |staff|
383
+ draw_staff_system(pdf, staff, baselines[group.staves.index(staff)], system, layout)
384
+ end
385
+ draw_staff_group_at(pdf, group, baselines.first, baselines.last) if group.staves.length > 1
386
+ end
387
+ draw_layout_items(pdf, layout, system)
388
+ end
389
+ end
276
390
 
277
- private
391
+ def draw_staff_system(pdf, staff, baseline, system, layout)
392
+ draw_staff_lines(pdf, baseline)
393
+ cursor = draw_clef(pdf, staff.clef, LEFT_PADDING - 24, baseline)
394
+ cursor = draw_key_signature(pdf, staff.key_signature, cursor + style.staff_space, baseline)
395
+ cursor = draw_time_signature(pdf, staff.time_signature, cursor + style.staff_space, baseline)
396
+ note_points = draw_measures(pdf, staff, [cursor + style.measure_padding, STAFF_START_X].max,
397
+ baseline, layout[:positions], layout, system: system)
398
+ draw_lyrics(pdf, staff, note_points, baseline)
399
+ end
400
+
401
+ def pdf_system_baseline(pdf, system, staff)
402
+ pdf.bounds.top - TOP_PADDING - system.line_top - system.staff_offset(staff.id)
403
+ end
404
+
405
+ def draw_header(pdf, score)
406
+ pdf.text_box(score.title, at: [LEFT_PADDING, pdf.cursor + 18], size: 16) if score.title
407
+ if score.composer
408
+ pdf.text_box(score.composer, at: [pdf.bounds.right - 180, pdf.cursor + 18], width: 180, size: 10, align: :right)
409
+ end
410
+ return unless score.tempo
411
+
412
+ pdf.text_box(tempo_text(score.tempo), at: [LEFT_PADDING, pdf.cursor], size: 9)
413
+ end
414
+
415
+ def draw_staff_group(pdf, group, first_baseline, last_staff_index)
416
+ last_baseline = pdf.cursor - TOP_PADDING - (last_staff_index * style.staff_gap)
417
+ draw_staff_group_at(pdf, group, first_baseline, last_baseline)
418
+ end
419
+
420
+ def draw_staff_group_at(pdf, group, first_baseline, last_baseline)
421
+ x = LEFT_PADDING - 36
422
+ case group.bracket_type
423
+ when :brace
424
+ pdf.text_box("{", at: [x, first_baseline + style.staff_space], size: [last_baseline - first_baseline, 28].max.abs)
425
+ when :bracket
426
+ pdf.stroke_line [x, first_baseline + style.staff_space * 0.5], [x, last_baseline - style.staff_space * 4.5]
427
+ pdf.stroke_line [x, first_baseline + style.staff_space * 0.5], [x + 8, first_baseline + style.staff_space * 0.5]
428
+ pdf.stroke_line [x, last_baseline - style.staff_space * 4.5], [x + 8, last_baseline - style.staff_space * 4.5]
429
+ end
430
+ end
431
+
432
+ def draw_element_with_context(pdf, element, x, baseline, staff, measure, accidental_state, note_points)
433
+ case element
434
+ when Clef::Core::Note
435
+ draw_note(pdf, element, x, baseline, staff.clef,
436
+ accidental_state: accidental_state,
437
+ key_signature: measure.key_signature || staff.key_signature)
438
+ note_points[element.object_id] = [x, pitch_to_y(element.pitch, baseline, staff.clef)]
439
+ when Clef::Core::Rest
440
+ draw_rest(pdf, element, x, baseline)
441
+ when Clef::Core::Chord
442
+ draw_chord(pdf, element, x, baseline, staff.clef,
443
+ accidental_state: accidental_state,
444
+ key_signature: measure.key_signature || staff.key_signature)
445
+ note_points[element.object_id] = [x, baseline]
446
+ when Clef::Core::Tuplet
447
+ draw_tuplet(pdf, element, x, baseline, staff.clef,
448
+ accidental_state: accidental_state,
449
+ key_signature: measure.key_signature || staff.key_signature,
450
+ note_points: note_points)
451
+ when Clef::Notation::Dynamic
452
+ draw_dynamic(pdf, element, x, baseline)
453
+ when Clef::Core::Tempo
454
+ draw_tempo_change(pdf, element, x, baseline)
455
+ end
456
+ end
457
+
458
+ def draw_dynamic(pdf, dynamic, x, baseline)
459
+ pdf.text_box(dynamic_text(dynamic), at: [x, baseline - (style.staff_space * 6)], size: 9)
460
+ end
461
+
462
+ def draw_tempo_change(pdf, tempo, x, baseline)
463
+ pdf.text_box(tempo_text(tempo), at: [x, baseline + (style.staff_space * 1.5)], size: 8)
464
+ end
465
+
466
+ def draw_flag(pdf, note, x, y, clef)
467
+ direction = Clef::Layout::Stem.direction(note, clef)
468
+ stem_len = style.staff_space * Clef::Layout::Stem.length(note, clef, direction)
469
+ stem_x = (direction == :up) ? x + 3 : x - 3
470
+ stem_y = (direction == :up) ? y + stem_len : y - stem_len
471
+ sweep = (direction == :up) ? -8 : 8
472
+ pdf.stroke_line [stem_x, stem_y], [stem_x + 8, stem_y + sweep]
473
+ end
474
+
475
+ def draw_beams(pdf, layout, staff, measure, voice_id, note_points)
476
+ Array(layout&.dig(:beams, staff.id, measure.number, voice_id)).each do |group|
477
+ points = group.filter_map { |note| note_points[note.object_id] }
478
+ next if points.length < 2
479
+
480
+ y = points.map(&:last).max + (style.staff_space * 3.5)
481
+ previous_width = pdf.line_width if pdf.respond_to?(:line_width)
482
+ pdf.line_width = style.beam_thickness if pdf.respond_to?(:line_width=)
483
+ pdf.stroke_line [points.first.first + 3, y], [points.last.first + 3, y]
484
+ pdf.line_width = previous_width if previous_width && pdf.respond_to?(:line_width=)
485
+ end
486
+ end
487
+
488
+ def draw_note_connections(pdf, elements, note_points)
489
+ notes = flatten_elements(elements).select { |element| element.is_a?(Clef::Core::Note) }
490
+ notes.each_with_index do |note, index|
491
+ draw_connection_to_next(pdf, note, notes[(index + 1)..], note_points, :tie) if note.tie_state == :start
492
+ draw_connection_to_next(pdf, note, notes[(index + 1)..], note_points, :slur) if note.slur_start
493
+ end
494
+ end
495
+
496
+ def draw_connection_to_next(pdf, note, candidates, note_points, kind)
497
+ target = (kind == :tie) ? candidates&.find { |candidate| candidate.pitch.enharmonic?(note.pitch) } : candidates&.find(&:slur_end)
498
+ return unless target
499
+
500
+ connection = (kind == :tie) ? Clef::Notation::Tie.new(note, target) : Clef::Notation::Slur.new(note, target)
501
+ return draw_ties(pdf, [connection], note_points: note_points) if kind == :tie
502
+
503
+ draw_slurs(pdf, [connection], note_points: note_points)
504
+ end
505
+
506
+ def draw_notation_connection(pdf, connection, note_points, lift:)
507
+ start_point = note_points[connection.start_note.object_id]
508
+ end_point = note_points[connection.end_note.object_id]
509
+ return unless start_point && end_point
510
+
511
+ if pdf.respond_to?(:stroke_curve)
512
+ pdf.stroke_curve [start_point[0] + 5, start_point[1] + 5],
513
+ [end_point[0] - 5, end_point[1] + 5],
514
+ bounds: [[start_point[0] + 18, start_point[1] + lift],
515
+ [end_point[0] - 18, end_point[1] + lift]]
516
+ else
517
+ pdf.stroke_line [start_point[0] + 5, start_point[1] + 5], [end_point[0] - 5, end_point[1] + 5]
518
+ end
519
+ end
520
+
521
+ def draw_lyrics(pdf, staff, note_points, baseline)
522
+ Array(staff.metadata[:lyrics]).each do |lyric|
523
+ elements = staff.measures.flat_map { |measure| Array(measure.voices[lyric.voice_id]&.elements) }
524
+ .then { |elements| lyric_elements(elements) }
525
+ lyric_events(lyric, elements).each do |event|
526
+ draw_lyric_event(pdf, event, note_points, baseline)
527
+ end
528
+ end
529
+ end
530
+
531
+ def draw_lyric_event(pdf, event, note_points, baseline)
532
+ y = baseline - (style.staff_space * 6)
533
+ case event[:type]
534
+ when :text
535
+ point = note_points[event[:element]&.object_id]
536
+ return unless point
537
+
538
+ pdf.text_box(event[:syllable], at: [point.first - 10, y], size: 8, width: 24, align: :center)
539
+ when :hyphen
540
+ from = note_points[event[:from]&.object_id]
541
+ to = note_points[event[:to]&.object_id]
542
+ return unless from && to
543
+
544
+ pdf.text_box("-", at: [((from.first + to.first) / 2.0) - 4, y], size: 8, width: 8, align: :center)
545
+ when :extender
546
+ from = note_points[event[:from]&.object_id]
547
+ to = note_points[event[:to]&.object_id]
548
+ return unless from && to
549
+
550
+ pdf.stroke_line [from.first + 8, y + 4], [to.first - 8, y + 4]
551
+ end
552
+ end
553
+
554
+ def draw_ledger_lines(pdf, x, y, baseline)
555
+ top = baseline
556
+ bottom = baseline - (style.staff_space * 4)
557
+ return if y.between?(bottom, top)
558
+
559
+ current = (y > top) ? top + style.staff_space : bottom - style.staff_space
560
+ while (y > top) ? current <= y : current >= y
561
+ pdf.stroke_line [x - 6, current], [x + 6, current]
562
+ current += (y > top) ? style.staff_space : -style.staff_space
563
+ end
564
+ end
565
+
566
+ def draw_key_signature(pdf, key_signature, x, baseline)
567
+ return x unless key_signature
568
+
569
+ accidentals = key_signature.accidentals
570
+ order = (accidentals[:type] == :sharp) ? NotationHelpers::SHARP_ORDER : NotationHelpers::FLAT_ORDER
571
+ text = (accidentals[:type] == :sharp) ? "#" : "b"
572
+ order.first(accidentals[:count].to_i).each_with_index do |note_name, index|
573
+ y = baseline - key_signature_y(note_name)
574
+ pdf.text_box(text, at: [x + (index * 8), y], size: 10)
575
+ end
576
+ x + (accidentals[:count].to_i * 8)
577
+ end
578
+
579
+ def draw_time_signature(pdf, time_signature, x, baseline)
580
+ return x unless time_signature
581
+
582
+ pdf.text_box(time_signature.numerator.to_s, at: [x, baseline - (style.staff_space * 0.9)], size: 10, align: :center, width: 10)
583
+ pdf.text_box(time_signature.denominator.to_s, at: [x, baseline - (style.staff_space * 2.7)], size: 10, align: :center, width: 10)
584
+ x + 14
585
+ end
278
586
 
279
587
  def prepare_canvas(pdf)
280
588
  font_name = font_manager.register_with(pdf)
281
589
  pdf.font(font_name)
282
590
  @smufl_enabled = (font_name != "Helvetica")
283
- rescue StandardError
591
+ rescue
284
592
  pdf.font("Helvetica")
285
593
  @smufl_enabled = false
286
594
  end
@@ -294,7 +602,9 @@ module Clef
294
602
  treble: "G",
295
603
  bass: "F",
296
604
  alto: "C",
297
- tenor: "C"
605
+ tenor: "C",
606
+ percussion: "||",
607
+ tab: "TAB"
298
608
  }.fetch(clef.type, clef.type.to_s[0].upcase)
299
609
  end
300
610
 
@@ -317,7 +627,7 @@ module Clef
317
627
  count = accidentals[:count].to_i
318
628
  return nil if count.zero?
319
629
 
320
- symbol = accidentals[:type] == :sharp ? "#" : "b"
630
+ symbol = (accidentals[:type] == :sharp) ? "#" : "b"
321
631
  "#{count}#{symbol}"
322
632
  end
323
633
 
@@ -328,7 +638,7 @@ module Clef
328
638
  end
329
639
 
330
640
  def pitch_to_y(pitch, baseline, clef)
331
- calculate_pitch_y(pitch, baseline, clef, vertical_axis: 1)
641
+ calculate_pitch_y(pitch, baseline, clef, vertical_axis: drawing_context.vertical_axis)
332
642
  end
333
643
 
334
644
  def staff_right_bound(pdf)
@@ -336,6 +646,101 @@ module Clef
336
646
 
337
647
  LEFT_PADDING + 500
338
648
  end
649
+
650
+ def x_for_moment(positions, moment, start_x, position_offset: 0.0)
651
+ return start_x unless positions
652
+
653
+ start_x + positions.fetch(moment, position_offset.to_f) - position_offset.to_f
654
+ end
655
+
656
+ def measure_length_for(measure)
657
+ return measure.time_signature.measure_length if measure.time_signature
658
+
659
+ measure.voices.values.map(&:total_length).max || Rational(0, 1)
660
+ end
661
+
662
+ def voice_vertical_offset(index)
663
+ return 0 if index.zero?
664
+
665
+ index.odd? ? -(style.staff_space * 0.8) : style.staff_space * 0.8
666
+ end
667
+
668
+ def rest_y(rest, baseline)
669
+ offset = {
670
+ whole: 2.0,
671
+ half: 2.2,
672
+ quarter: 1.6,
673
+ eighth: 1.4,
674
+ sixteenth: 1.4
675
+ }.fetch(rest.duration.base, 1.4)
676
+ baseline - (style.staff_space * offset)
677
+ end
678
+
679
+ def rest_glyph_key(duration)
680
+ {
681
+ whole: :rest_whole,
682
+ half: :rest_half,
683
+ quarter: :rest_quarter,
684
+ eighth: :rest_8th,
685
+ sixteenth: :rest_16th
686
+ }.fetch(duration.base, :rest_quarter)
687
+ end
688
+
689
+ def key_signature_y(note_name)
690
+ {
691
+ f: style.staff_space * 0.5,
692
+ c: style.staff_space * 2.0,
693
+ g: style.staff_space * 0.1,
694
+ d: style.staff_space * 1.6,
695
+ a: style.staff_space * 3.0,
696
+ e: style.staff_space * 1.1,
697
+ b: style.staff_space * 2.6
698
+ }.fetch(note_name)
699
+ end
700
+
701
+ def chord_note_offsets(notes)
702
+ notes.each_with_index.map do |note, index|
703
+ previous = notes[index - 1]
704
+ (previous && (diatonic_step(note.pitch) - diatonic_step(previous.pitch)).abs == 1) ? 6 : 0
705
+ end
706
+ end
707
+
708
+ def accidental_offset(index)
709
+ index * 4
710
+ end
711
+
712
+ def tempo_text(tempo)
713
+ "#{tempo.beat_unit.to_lilypond} = #{tempo.bpm}"
714
+ end
715
+
716
+ def draw_layout_items(pdf, layout, system)
717
+ Array(layout[:items]).each do |item|
718
+ next unless item.type == :text
719
+ next unless system.include_moment?(item.moment)
720
+
721
+ x = x_for_moment(layout[:positions], item.moment, STAFF_START_X, position_offset: system.position_offset)
722
+ y = pdf.bounds.top - TOP_PADDING - system.line_top + style.staff_space
723
+ pdf.text_box(item.payload.fetch(:text), at: [x, y], size: 8)
724
+ end
725
+ end
726
+
727
+ def drawing_context
728
+ @drawing_context ||= Clef::Renderer::DrawingContext.pdf
729
+ end
730
+
731
+ def render_to_io(score, io, positions:, layout:)
732
+ pdf = Prawn::Document.new(page_size: style.page_size, margin: style.margin)
733
+ prepare_canvas(pdf)
734
+ draw_score(pdf, score, positions, layout: layout)
735
+ io.write(pdf.render)
736
+ end
737
+
738
+ def ensure_parent_directory!(path)
739
+ parent = File.dirname(path.to_s)
740
+ return if parent.nil? || parent == "." || Dir.exist?(parent)
741
+
742
+ raise ArgumentError, "output directory does not exist: #{parent}"
743
+ end
339
744
  end
340
745
  end
341
746
  end