md2slides 0.0.0 → 0.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12551985c4e0c6e7f6f9ce31b27a275646ec17db9c9d8b05126d8ae08c15e643
4
- data.tar.gz: 94448d9c4ffe77da49be0fcade0d448dc280b01a0972fc17208ff5118c1feea3
3
+ metadata.gz: f4a24667a8575a6e6cde9c235ef8647891bab980c81f17a346d3b8e292fce32d
4
+ data.tar.gz: 5b26d1ce769583990de6ede00e0647ae2fea46e4f352a352e2db740ef14964c6
5
5
  SHA512:
6
- metadata.gz: 1aed59d8f309f1a29f0aa16561428c11a2d4c03d159e16625c3421f4870f615697264f60a52645580c125ea83fd7ed23ec483e59361b43ef68f27a619deac3e1
7
- data.tar.gz: 18de9e2866c8a658f7f8de8bf7f73ae818ce7b4f835f7fa6225e42bf558fc53ab8f13923aa031fed35426716abb05c17937945a52aa867ef4fb66afd6187dcf6
6
+ metadata.gz: 4dbe7420d2a68f51fb381674e228e7c99cc7c1afbd5aba195355c93a50031c31f8ccdb443eb21d817892c9800e916a3198fd29a36855db0e8f46866da3263ac7
7
+ data.tar.gz: 97390985806cf9a63ac0543ce8e5f88362b48138fc3ffe26b819ff7b758dc49a2dfcfadb455995ac6ec1ef54b271c2b73303d898a38af1b42fe869bff235e572
data/bin/md2slides CHANGED
@@ -5,23 +5,22 @@
5
5
 
6
6
  $:.unshift File.join(File.dirname(File.dirname(File.realpath(__FILE__))), 'lib')
7
7
 
8
- require 'presentation'
9
- require 'md'
8
+ require 'md2slides'
10
9
 
11
10
  def usage(errmsg = nil)
12
11
  puts "ERROR: #{errmsg}" if errmsg
13
12
  puts <<~EOF
14
13
  Usage:
15
- #{$progname} [-f] <file> [<id>]
14
+ #{$progname} [-f] <file> [<URL or id>]
16
15
  #{$progname} -h
17
16
 
18
17
  Description:
19
18
  generate a Google Presentation file.
20
19
 
21
20
  Argument:
22
- -h: output this message.
23
- <file>: a file written in markdown
24
- <id>: a ID of a presentation
21
+ -h: output this message.
22
+ <file>: a file written in markdown
23
+ <URL or id>: a URL or a ID of a presentation
25
24
 
26
25
  BUGS:
27
26
  only .md is allowed for an input file for now.
@@ -47,15 +46,15 @@ else
47
46
  end
48
47
 
49
48
  md = MD.new(path)
50
- id = md.attributes[:id]
51
- if id.nil?
52
- id = ARGV.shift
53
- usage("No ID specified in the presentation and the argument") if ARGV.size != 0
49
+ url = md.attributes[:url]
50
+ if url.nil?
51
+ url = ARGV.shift
52
+ usage("No URL and ID specified in the presentation and the argument") if ARGV.size != 0
54
53
  else
55
- usage("ID duplicatedly specified!!") if ARGV.size != 0
54
+ usage("URL or ID duplicatedly specified!!") if ARGV.size != 0
56
55
  end
57
56
 
58
- presentation = Presentation.new(id)
57
+ presentation = Presentation.new(url)
59
58
  presentation.list
60
59
  presentation.update(md)
61
60
  #presentation.stick_out_check
@@ -0,0 +1,50 @@
1
+ class Presentation
2
+ def generate_audio0(i, slide, dir)
3
+ print "slide \##{i + 1}: generating audio... "
4
+ notes = get_slide_note(slide)
5
+ path = __data_slide_path(i, '.m4a', dir)
6
+ if notes
7
+ opath = __data_slide_path(i, '.mp3', dir)
8
+ opath = Presentation::text_to_speech(notes, opath)
9
+ #
10
+ # convert to .m4a, which contains duration in meta data.
11
+ # this prevents unreasonable audio duration when combining
12
+ # audio and video.
13
+ #
14
+ heading_silence = 2
15
+ trailing_silence = 1
16
+ audiorate = 24000
17
+ cmd = <<~CMD
18
+ ffmpeg -hide_banner -y \
19
+ -f lavfi -t #{heading_silence} \
20
+ -i anullsrc=r=#{audiorate}:cl=mono \
21
+ -i #{opath} \
22
+ -f lavfi -t #{trailing_silence} \
23
+ -i anullsrc=r=#{audiorate}:cl=mono \
24
+ -filter_complex "[0:a][1:a][2:a]concat=n=3:v=0:a=1[out]" \
25
+ -map "[out]" \
26
+ -c:a aac -b:a 64k #{path}
27
+ CMD
28
+ msg, errmsg, status = Open3.capture3(cmd)
29
+ File.delete(opath) rescue
30
+ if ! status.success?
31
+ raise("ERROR: cannot convert audio: #{errmsg}")
32
+ end
33
+ puts 'done'
34
+ else
35
+ begin
36
+ File.delete(path)
37
+ rescue Errno::ENOENT => e
38
+ # okay
39
+ end
40
+ puts "skip (no notes)"
41
+ end
42
+ end
43
+
44
+ def generate_audio(dir = nil)
45
+ dir = __data_path(dir)
46
+ @presentation.slides.each_with_index do |slide, i|
47
+ generate_audio0(i, slide, dir)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,148 @@
1
+ class MD
2
+ class Page
3
+ include Enumerable
4
+
5
+ class Element
6
+ attr_reader :type, :value
7
+ def initialize(type, value)
8
+ @type, @value = type, value
9
+ end
10
+
11
+ def to_s
12
+ "#{@type.to_s}: #{@value}"
13
+ end
14
+ end
15
+
16
+ def initialize
17
+ @elements = []
18
+ @comments = []
19
+ end
20
+
21
+ def add(type, value)
22
+ return if value.nil? || value.empty?
23
+ @elements.push(Element.new(type, value))
24
+ end
25
+
26
+ def add_comment(c)
27
+ return if c.nil? || c.empty?
28
+ @comments.push(c)
29
+ end
30
+
31
+ def comments
32
+ return nil if @comments.empty?
33
+ @comments.join("\n")
34
+ end
35
+
36
+ def has_comments?
37
+ ! @comments.empty?
38
+ end
39
+
40
+ def has_title?
41
+ @elements[0]&.type == :h1
42
+ end
43
+
44
+ def title
45
+ has_title? && @elements[0].value
46
+ end
47
+
48
+ def title_only?
49
+ has_title? && @elements.size == 1
50
+ end
51
+
52
+ def title_subtitle_only?
53
+ has_title? && @elements.size == 2 &&
54
+ @elements[1]&.type == :h2
55
+ end
56
+
57
+ def subtitle
58
+ title_subtitle_only? && @elements[1]&.value
59
+ end
60
+
61
+ def each(&block)
62
+ @elements.drop(has_title? ? 1 : 0).each(&block)
63
+ end
64
+ end
65
+
66
+ attr_reader :attributes
67
+
68
+ def initialize(path)
69
+ @pages = []
70
+ @attributes = {}
71
+ load(path)
72
+ end
73
+
74
+ def __filename_sanitize(s)
75
+ s.gsub(/[\/\\:\*\?"<>\|]/, '')
76
+ end
77
+
78
+ def each(&block)
79
+ @pages.each(&block)
80
+ end
81
+
82
+ def each_with_index(&block)
83
+ @pages.each_with_index(&block)
84
+ end
85
+
86
+ def parse_header(text)
87
+ text.each_line do |l|
88
+ l.strip!
89
+ next if l.empty?
90
+ if l =~ /^([^:]+): *([^ ].*)$/
91
+ k, v = $1.strip, $2.strip
92
+ @attributes[k.to_sym] = v
93
+ else
94
+ raise("ERROR: invalid line in a header: #{l}")
95
+ end
96
+ end
97
+ end
98
+
99
+ def parse_page(text)
100
+ @pages << page = Page.new
101
+ is_in_comment = false
102
+ text.each_line do |l|
103
+ l.strip!
104
+ next if l.empty?
105
+ if is_in_comment
106
+ if l =~ /(.*) ?-->(.*)$/
107
+ c, left = $1, $2
108
+ page.add_comment(c)
109
+ page.add(:p, left)
110
+ is_in_comment = false
111
+ else
112
+ page.add_comment(l)
113
+ end
114
+ else
115
+ case l
116
+ when /^(#+) *(.*)$/
117
+ sharps, title = $1, $2
118
+ h = "h#{sharps.size}"
119
+ page.add(h.to_sym, title)
120
+ when /^[-*] *(.*)$/
121
+ page.add(:li, $1)
122
+ when /^<!-- *(.*)$/
123
+ l = $1
124
+ if l =~ /(.*) ?-->(.*)$/
125
+ c, left = $1, $2
126
+ page.add_comment(c)
127
+ page.add(:p, left)
128
+ else
129
+ is_in_comment = true
130
+ page.add_comment(l)
131
+ end
132
+ else
133
+ page.add(:p, l)
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ def load(path)
140
+ File.read(path).split('---').each do |text|
141
+ if @attributes.empty?
142
+ parse_header(text)
143
+ next
144
+ end
145
+ parse_page(text)
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,428 @@
1
+ require "googleauth"
2
+ #require "google/apis/people_v1"
3
+ require "google/apis/slides_v1"
4
+ require "google/apis/drive_v3"
5
+
6
+ class Presentation
7
+ APPLICATION_NAME = 'md2slides'
8
+
9
+ GOOGLE_OAUTH_SCOPE = [
10
+ "https://www.googleapis.com/auth/drive",
11
+ # "https://www.googleapis.com/auth/userinfo.email",
12
+ # "https://www.googleapis.com/auth/userinfo.profile",
13
+ "https://www.googleapis.com/auth/presentations",
14
+ ]
15
+
16
+ attr_reader :id
17
+
18
+ def self.filename_sanitize(s)
19
+ s.gsub(/[\/\\:\*\?"<>\|]/, '')
20
+ end
21
+
22
+ def initialize(url = nil)
23
+ if url =~ %r{https://docs.google.com/presentation/d/([^\/ ]+).*$}
24
+ @id = $1
25
+ elsif url
26
+ raise("ERROR: invalid URL: #{url}")
27
+ end
28
+
29
+ @authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
30
+ json_key_io: File.open(ENV['GOOGLE_APPLICATION_CREDENTIALS']),
31
+ scope: GOOGLE_OAUTH_SCOPE)
32
+ @authorizer.fetch_access_token!
33
+
34
+ @slides_service = Google::Apis::SlidesV1::SlidesService.new
35
+ @slides_service.client_options.application_name = APPLICATION_NAME
36
+ @slides_service.authorization = @authorizer
37
+
38
+ @drive_service = Google::Apis::DriveV3::DriveService.new
39
+ @drive_service.client_options.application_name = APPLICATION_NAME
40
+ @drive_service.authorization = @authorizer
41
+
42
+ if @id
43
+ @presentation = @slides_service.get_presentation(@id)
44
+ end
45
+
46
+ @requests = []
47
+
48
+ # XXX: this script always runs by an API user...
49
+ #people_service = Google::Apis::PeopleV1::PeopleServiceService.new
50
+ #people_service.authorization = @authorizer
51
+ #profile = people_service.get_person("people/me", person_fields: "names,emailAddresses")
52
+ #name = profile.names.first.display_name
53
+ #email = profile.email_addresses.first.value
54
+ #puts "#{name}: #{email}"
55
+ end
56
+
57
+ def exists?
58
+ @id && @presentation
59
+ end
60
+
61
+ def existence_check
62
+ if ! exists?
63
+ raise("presentation (ID: #{@id})does not exists!!!")
64
+ end
65
+ end
66
+
67
+ def url
68
+ @id && "https://docs.google.com/presentation/d/#{@id}/edit"
69
+ end
70
+
71
+ def extent
72
+ existence_check
73
+ width = @presentation.page_size.width.magnitude
74
+ height = @presentation.page_size.height.magnitude
75
+ unit = @presentation.page_size.width.unit
76
+ return width, height, unit
77
+ end
78
+
79
+ def width
80
+ extent[0]
81
+ end
82
+
83
+ def height
84
+ extent[1]
85
+ end
86
+
87
+ def stick_out_check
88
+ msgs = []
89
+ @presentation.slides.each_with_index do |slide, i|
90
+ slide.page_elements.each do |element|
91
+ next unless element.shape
92
+ next unless element.transform
93
+ x1 = element.transform.translate_x || 0
94
+ x2 = x1 + element.size.width.magnitude rescue 0
95
+ y1 = element.transform.translate_y || 0
96
+ y2 = y1 + element.size.width.magnitude rescue 0
97
+ puts "width: #{width}, height: #{height}"
98
+ if x2 > width || y2 > height || x1 < 0 || y1 < 0
99
+ msgs.push("slide #{i + 1}: sticking out: (#{x1},#{y1})-(#{x2},#{y2})")
100
+ end
101
+ end
102
+ end
103
+ raise(msgs.join("\n")) unless msgs.empty?
104
+ end
105
+
106
+ def create(name)
107
+ if exists?
108
+ raise("already presentation exists!!")
109
+ end
110
+ presentation0 = Google::Apis::SlidesV1::Presentation.new(title: name)
111
+ @presentation = @slides_service.create_presentation(presentation0)
112
+ @id = @presentation.presentation_id
113
+ puts "Create presentation #{name}: #{url}"
114
+ end
115
+
116
+ def delete
117
+ existence_check
118
+ begin
119
+ @drive_service.delete_file(@id)
120
+ @presentation = nil
121
+ puts "deleted ID: #{@id}"
122
+ rescue Google::Apis::ClientError => e
123
+ puts "ERROR: failed deleting #{@id}: #{e.full_message}"
124
+ end
125
+ end
126
+
127
+ def share(user)
128
+ existence_check
129
+ permission = Google::Apis::DriveV3::Permission.new(
130
+ type: 'user',
131
+ role: 'writer', # 'reader', 'commenter', 'writer'
132
+ email_address: user
133
+ )
134
+ @drive_service.create_permission(@id, permission, fields: "id")
135
+ end
136
+
137
+ def __request
138
+ return if @requests.empty?
139
+ batch_update_request = Google::Apis::SlidesV1::BatchUpdatePresentationRequest.new(requests: @requests)
140
+ begin
141
+ @slides_service.batch_update_presentation(@id, batch_update_request)
142
+ @requests = []
143
+ rescue => e
144
+ p e.body
145
+ raise e
146
+ end
147
+ @presentation = @slides_service.get_presentation(@id)
148
+ end
149
+
150
+ def delete_object(object_id)
151
+ @requests.push({
152
+ delete_object: {
153
+ object_id_prop: object_id,
154
+ }
155
+ })
156
+ end
157
+
158
+ def create_slide(layout = 'TITLE_AND_BODY', insertion_index = nil)
159
+ insertion_index ||= @presentation.slides&.size.to_i # at the tail
160
+ #
161
+ # layout can be:
162
+ # https://developers.google.com/apps-script/reference/slides/predefined-layout
163
+ #
164
+ @requests.push({
165
+ create_slide: {
166
+ #
167
+ # XXX: object ID should be specified for performance...
168
+ #
169
+ # object_id_prop: object_id_prop,
170
+ insertion_index: insertion_index,
171
+ slide_layout_reference: {
172
+ predefined_layout: layout,
173
+ }
174
+ }
175
+ })
176
+ __request
177
+ @presentation.slides[insertion_index]
178
+ end
179
+
180
+ def delete_text(element)
181
+ return if ! element.shape.instance_variable_defined?(:@placeholder)
182
+ return if ! element.shape.instance_variable_defined?(:@text)
183
+ @requests.push({
184
+ delete_text: {
185
+ object_id_prop: element.object_id_prop,
186
+ text_range: {
187
+ type: 'ALL'
188
+ }
189
+ }
190
+ })
191
+ end
192
+
193
+ def update_text(element, text)
194
+ delete_text(element)
195
+ return if text.nil? || text.empty?
196
+ @requests.push({
197
+ insert_text: {
198
+ object_id_prop: element.object_id_prop,
199
+ insertion_index: 0,
200
+ text: text
201
+ }
202
+ })
203
+ end
204
+
205
+ def set_bullet(element, bullet)
206
+ return if bullet.nil?
207
+ @requests.push({
208
+ create_paragraph_bullets: {
209
+ object_id_prop: element.object_id_prop,
210
+ text_range: {
211
+ type: 'ALL',
212
+ },
213
+ bullet_preset: bullet,
214
+ }
215
+ })
216
+ end
217
+
218
+ def find_element(slide, type)
219
+ slide&.page_elements&.find do |e|
220
+ case e.shape&.placeholder&.type
221
+ when type
222
+ true
223
+ # else
224
+ # p e.shape&.placeholder&.type
225
+ # false
226
+ end
227
+ end
228
+ end
229
+
230
+ def __get_element_text(element)
231
+ element&.shape&.text&.text_elements&.collect { |e| e.text_run&.content&.strip }&.compact&.join("\n")
232
+ end
233
+
234
+ def get_slide_text(slide, re = 'BODY')
235
+ __get_element_text(find_element(slide, re))
236
+ end
237
+
238
+ def get_title
239
+ slide = @presentation&.slides.first
240
+ title = get_slide_text(slide, /(^TITLE|[^a-zA-Z]TITLE)$/)
241
+ subtitle = get_slide_text(slide, 'SUBTITLE')
242
+ return title, subtitle
243
+ end
244
+
245
+ def set_slide_text(slide, text, re = 'BODY', bullet = 'BULLET_DISC_CIRCLE_SQUARE')
246
+ element = find_element(slide, re)
247
+ if element.nil?
248
+ raise("ERROR: no text element found!!!")
249
+ end
250
+ update_text(element, text)
251
+ set_bullet(element, bullet)
252
+ end
253
+
254
+ def set_slide_title(slide, title, re = /(^TITLE|[^a-zA-Z]TITLE)$/)
255
+ set_slide_text(slide, title, re, nil)
256
+ end
257
+
258
+ def set_slide_subtitle(slide, title)
259
+ set_slide_title(slide, title, 'SUBTITLE')
260
+ end
261
+
262
+ def set_title(title, subtitle = nil)
263
+ existence_check
264
+ if @presentation&.slides&.size.to_i == 0
265
+ create_slide('TITLE')
266
+ end
267
+ slide = @presentation.slides.first
268
+ set_slide_title(slide, title)
269
+ set_slide_subtitle(slide, subtitle)
270
+ __request
271
+ end
272
+
273
+ def get_slide_note(slide)
274
+ notes = slide.slide_properties.notes_page
275
+ __get_element_text(find_element(notes, 'BODY'))
276
+ end
277
+
278
+ def set_slide_note(slide, text)
279
+ notes = slide.slide_properties.notes_page
280
+ element = find_element(notes, 'BODY')
281
+ if element.nil?
282
+ raise("ERROR: no text box element found in notes!!!")
283
+ end
284
+ update_text(element, text)
285
+ end
286
+
287
+ def clear
288
+ existence_check
289
+ n = @presentation.slides&.size.to_i
290
+ while n > 0
291
+ n -= 1
292
+ delete_object(@presentation.slides[n].object_id_prop)
293
+ end
294
+ __request
295
+ end
296
+
297
+ def list
298
+ existence_check
299
+ puts "title: #{@presentation.title}"
300
+ puts "pages: #{@presentation.slides.size}"
301
+ @presentation.slides.each_with_index do |slide, i|
302
+ puts "- Slide \##{i + 1} contains #{slide.page_elements.count} elements."
303
+ end
304
+ end
305
+
306
+ def list_all
307
+ query = "mimeType = 'application/vnd.google-apps.presentation' and trashed = false"
308
+ response = @drive_service.list_files(q: query, fields: 'files(id, name)', page_size: 100)
309
+ response.files.each do |file|
310
+ puts "#{file.name}: #{file.id}"
311
+ end
312
+ end
313
+
314
+ def update(md)
315
+ #
316
+ # XXX: currently, clear all slides...
317
+ #
318
+ if exists?
319
+ clear
320
+ end
321
+ md.each do |page|
322
+ if page.title_subtitle_only?
323
+ layout = 'TITLE'
324
+ elsif page.title_only?
325
+ layout = 'SECTION_HEADER'
326
+ else
327
+ layout = 'TITLE_AND_BODY'
328
+ end
329
+ slide = create_slide(layout)
330
+ if page.has_title?
331
+ set_slide_title(slide, page.title)
332
+ end
333
+ if page.title_subtitle_only?
334
+ set_slide_subtitle(slide, page.subtitle)
335
+ else
336
+ texts = page.map do |e|
337
+ case e.type.to_s
338
+ when /^h([0-9]+)$/
339
+ n = $1.to_i - 1
340
+ else
341
+ n = 1
342
+ end
343
+ "\t" * n + e.value
344
+ end.join("\n")
345
+ if texts.size > 0
346
+ set_slide_text(slide, texts)
347
+ # set_slide_text(slide, texts,
348
+ # 'BODY', 'NUMBERED_DIGIT_ALPHA_ROMAN')
349
+ end
350
+ end
351
+ if page.has_comments?
352
+ set_slide_note(slide, page.comments)
353
+ end
354
+ end
355
+ __request
356
+ end
357
+
358
+ def __data_path(basedir)
359
+ if basedir.nil?
360
+ basedir = File.join(BASEDIR, 'data')
361
+ end
362
+ title, subtitle = get_title
363
+ title = self.class.filename_sanitize(title)
364
+ subtitle = self.class.filename_sanitize(subtitle)
365
+ File.join(basedir, "#{title}-#{subtitle}-#{@id}")
366
+ end
367
+
368
+ def __data_slide_path(i, ext, basedir = nil)
369
+ path = "slide-#{i + 1}#{ext}"
370
+ if basedir
371
+ path = File.join(basedir, path)
372
+ end
373
+ path
374
+ end
375
+
376
+ # XXX: these do not work when the presentation is not shared globally.
377
+ def export_url(i)
378
+ #@id && "https://docs.google.com/presentation/d/#{@id}/export/png?id=#{@id}&pageid=#{slide.object_id_prop}&width=1920&height=1080"
379
+ @id && "https://docs.google.com/presentation/d/#{@id}/export/png?id=#{@id}&pageid=p#{i}&width=1920&height=1080"
380
+ end
381
+
382
+ # XXX: these do not work when the presentation is not shared globally.
383
+ def download_slide0(i, slide, dir)
384
+ url = export_url(i)
385
+ URI.open(url) do |remote_file|
386
+ path = __data_slide_path(i, '.png', dir)
387
+ File.open(path, 'wb') do |file|
388
+ file.write(remote_file.read)
389
+ end
390
+ end
391
+ end
392
+
393
+ def download_slide(i, slide, dir)
394
+ print "slide #{i}: downloading..."
395
+ #
396
+ # a size of a width can be: SMALL (200), MEDIUM (800), LARGE (1600)
397
+ # https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations.pages/getThumbnail
398
+ #
399
+ thumbnail = @slides_service.get_presentation_page_thumbnail(
400
+ @id, slide.object_id_prop, thumbnail_properties_thumbnail_size: 'LARGE')
401
+ URI.open(thumbnail.content_url) do |remote_file|
402
+ path = __data_slide_path(i, '.png', dir)
403
+ File.open(path, 'wb') do |file|
404
+ file.write(remote_file.read)
405
+ end
406
+ end
407
+ puts 'done'
408
+ end
409
+
410
+ def download(dir = nil)
411
+ existence_check
412
+ dir = __data_path(dir)
413
+ parent = File.dirname(dir)
414
+ files = Dir.glob("#{File.join(parent, "*#{@id}*")}")
415
+ if files.empty?
416
+ FileUtils.mkdir_p(dir)
417
+ elsif files[0] != dir
418
+ File.rename(files[0], dir)
419
+ end
420
+ lockfile = File.join(dir, '.lock')
421
+ File.open(lockfile, File::RDWR|File::CREAT, 0644) do |f|
422
+ f.flock(File::LOCK_EX | File::LOCK_NB)
423
+ @presentation.slides.each_with_index do |slide, i|
424
+ download_slide(i, slide, dir)
425
+ end
426
+ end
427
+ end
428
+ end
@@ -0,0 +1,41 @@
1
+ #
2
+ # this requires:
3
+ # gem install google-cloud-text_to_speech
4
+ #
5
+ require 'google/cloud/text_to_speech'
6
+
7
+ class Presentation
8
+ def self.text_to_speech(text, filename)
9
+ extname = File.extname(filename)
10
+ if extname
11
+ if filename =~ /^(.*)#{extname}$/
12
+ filename = $1
13
+ else
14
+ raise("invalid extname #{extname} in \"#{filename}\"")
15
+ end
16
+ end
17
+ filename += '.mp3'
18
+
19
+ response = Google::Cloud::TextToSpeech.new.synthesize_speech(
20
+ # XXX: I don't know how to create an object of Google::Cloud::TextToSpeech::SynthesisInput...
21
+ { text: text },
22
+ #
23
+ # Standard is the cheapest.
24
+ # ja-JP-Standard-A, B female
25
+ # ja-JP-Standard-C, D male
26
+ # Wavenet is more expensive than standard.
27
+ # https://cloud.google.com/text-to-speech/docs/voices?hl=ja
28
+ # https://cloud.google.com/text-to-speech/pricing?hl=ja
29
+ #
30
+ #{ language_code: 'ja-JP', name: 'ja-JP-Wavenet-B' },
31
+ { language_code: 'ja-JP', name: 'ja-JP-Standard-D' },
32
+ { audio_encoding: :MP3 },
33
+ )
34
+
35
+ File.open(filename, 'wb') do |file|
36
+ file.write(response.audio_content)
37
+ end
38
+
39
+ return filename
40
+ end
41
+ end
@@ -0,0 +1,61 @@
1
+ require 'open3'
2
+
3
+ class Presentation
4
+ def generate_slide_video(i, dir)
5
+ img = __data_slide_path(i, '.png', dir)
6
+ audio = __data_slide_path(i, '.m4a', dir)
7
+ video = __data_slide_path(i, '.mp4', dir)
8
+
9
+ # XXX: Google Text to Speech produces this...
10
+ audiorate = 24000
11
+
12
+ if File.exists?(audio)
13
+ audioin = "-i \"#{audio}\""
14
+ timeopt = '-shortest'
15
+ else
16
+ audioin = "-f lavfi -i aevalsrc=0"
17
+ timeopt = "-vframes 60"
18
+ end
19
+ cmd = <<~CMD
20
+ ffmpeg -hide_banner -y \
21
+ -framerate 15 -loop 1 -i "#{img}" \
22
+ #{audioin} -map 0:v:0 -map 1:a:0 \
23
+ -c:v libx264 -tune stillimage \
24
+ -c:a aac -ar #{audiorate} -ac 1 \
25
+ -pix_fmt yuv420p #{timeopt} "#{video}"
26
+ CMD
27
+ msg, errmsg, status = Open3.capture3(cmd)
28
+ if ! status.success?
29
+ raise("ERROR: cannot produce video: #{errmsg}")
30
+ end
31
+ end
32
+
33
+ def generate_video(dir = nil)
34
+ dir = __data_path(dir)
35
+ @presentation.slides.each_with_index do |slide, i|
36
+ print "slide \##{i + 1}: generating video..."
37
+ generate_slide_video(i, dir)
38
+ puts "done"
39
+ end
40
+
41
+ print "concatenate video files..."
42
+ videolist = File.join(dir, 'video-list.txt')
43
+ File.open(videolist, 'w') do |f|
44
+ @presentation.slides.each_with_index do |slide, i|
45
+ f.puts("file #{__data_slide_path(i, '.mp4')}")
46
+ end
47
+ end
48
+ video = File.join(dir, 'video.mp4')
49
+ cmd = <<~CMD
50
+ cd "#{dir}" && \
51
+ ffmpeg -hide_banner -y -f concat -safe 0 \
52
+ -i "#{videolist}" -c copy "#{video}"
53
+ CMD
54
+ msg, errmsg, status = Open3.capture3(cmd)
55
+ if ! status.success?
56
+ raise("ERROR: cannot produce video: #{errmsg}")
57
+ else
58
+ puts 'done'
59
+ end
60
+ end
61
+ end
data/lib/md2slides.rb ADDED
@@ -0,0 +1,7 @@
1
+ $:.unshift File.dirname(File.dirname(File.realpath(__FILE__)))
2
+ require 'config/config'
3
+ require 'md2slides/md'
4
+ require 'md2slides/presentation'
5
+ require 'md2slides/text_to_speech'
6
+ require 'md2slides/audio'
7
+ require 'md2slides/video'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: md2slides
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Motoyuki OHMORI
@@ -10,7 +10,7 @@ bindir: bin
10
10
  cert_chain: []
11
11
  date: 2025-04-29 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: Markdown to presentation slides in ruby
13
+ description: Generate Google slides and its video file from a markdown file
14
14
  email: ohmori@tottori-u.ac.jp
15
15
  executables:
16
16
  - md2slides
@@ -18,6 +18,12 @@ extensions: []
18
18
  extra_rdoc_files: []
19
19
  files:
20
20
  - bin/md2slides
21
+ - lib/md2slides.rb
22
+ - lib/md2slides/audio.rb
23
+ - lib/md2slides/md.rb
24
+ - lib/md2slides/presentation.rb
25
+ - lib/md2slides/text_to_speech.rb
26
+ - lib/md2slides/video.rb
21
27
  homepage: https://github.com/ohmori7/md2slides
22
28
  licenses:
23
29
  - MIT