md2slides 0.0.3 → 0.0.5

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: b7ead7e0a1d48738b1f88a039ec73924afcab9eb35ea0a3bf15f6933d655690e
4
- data.tar.gz: dfe7efa2cc63e30e8c71dc15c74b784725e812fed5dd619034393f4dd97bc772
3
+ metadata.gz: 8940d8d5f290e134eae76153e0e1761a9133be69d0abfdb3ba63ef2ee0076906
4
+ data.tar.gz: d0d09ed3fc8cd268c8126445c0edcd66aa7cfb7f16832b6819e9bdca57f79ecf
5
5
  SHA512:
6
- metadata.gz: 33410bc7876fcb1b24f24628d73a66a3d5314cb34b6a9460301b68c95fc22e5e2ce6677559f66eebff3df6d4742f148ab91007dfe63de4f56e08b2009c780050
7
- data.tar.gz: bbed92243410bd696a193a4859e9076590690946662b29c792ef6bec9b5c33e68adc8fd5c760b0490019cc2a17226b0911ba2bc96a06811f0447d287c41abb7c
6
+ metadata.gz: f50b81972a9c0bcb5308f1cb37a3b748a154b5435b19b6438518f58d270ef437e486f2fc2fd598f574eca89ade8bc604176dbd43efe4e0e7f899999ec17cbbe6
7
+ data.tar.gz: 9c326d70264073f5f0d5aa1c5c9ce052732d4dae7c6cecaa9a80b19f6cd6888d88a451f7d5ee340058c0aeefb264b8886e7c6fe14ce8d280daf94263f56f629b
data/README.md CHANGED
@@ -3,8 +3,27 @@
3
3
  A video file of a presentation will be also generated, and it may contains narration if supplied.
4
4
  Narration can be given as notes in slides, and the audio will be generated using Google text-to-speech API and ffmpeg.
5
5
 
6
+ ## Installation
7
+
8
+ install by gem:
9
+
10
+ % gem install md2slides
11
+
12
+ Or add this line to your application's Gemfile:
13
+
14
+ ```ruby
15
+ gem 'md2slides'
16
+ ```
17
+
18
+ and then execute:
19
+
20
+ $ bundle
21
+
6
22
  ## Quick start
7
- 1. Generate your Google API credentials and copy it to credentails.json:
23
+ 1. Generate your Google API credentials on API service page of your Google cloud console:
24
+ https://console.cloud.google.com/apis/
25
+
26
+ 2. copy API credentials:
8
27
  ```
9
28
  % mkdir ~/.config/gcloud
10
29
  % cat >> ~/.config/gcloud/credentials.json
@@ -22,14 +41,10 @@ Narration can be given as notes in slides, and the audio will be generated using
22
41
  }
23
42
  ^D (this means CTRL + D)
24
43
  ```
25
- 2. install ffmpeg if necessary (if producing video file).
26
44
 
27
- 3. install necessary gems.
28
- ```
29
- % bundle install --path vendor/bundle
30
- ```
45
+ 3. install ffmpeg if necessary (if producing video file).
31
46
 
32
- 4. create an empty Google slide.
47
+ 4. create an empty Google slide, share it with **client_email** user, and copy the URL.
33
48
 
34
49
  5. write a markdown file.
35
50
  ```
@@ -53,33 +68,21 @@ narration text...
53
68
  ^D (this means CTRL + D)
54
69
  ```
55
70
 
56
- 6. execute.
71
+ 6. run the script.
57
72
  ```
58
- bundle exec bin/md2slides doc/sample.md
73
+ % md2slides deploy sample.md
59
74
  ```
60
75
 
61
- 7. the Google presentation is updated, and the video is stored in the current directory.
62
-
63
- ## Installation
64
-
65
- Add this line to your application's Gemfile:
66
-
67
- ```ruby
68
- gem 'md2slides'
69
- ```
70
-
71
- And then execute:
72
-
73
- $ bundle
74
-
75
- Or install it yourself as:
76
-
77
- $ gem install md2slides
76
+ the Google presentation is updated, and the video is stored in the current directory.
78
77
 
79
78
  ## Usage
80
79
 
81
80
  TODO: Write usage instructions here
82
81
 
82
+ ## TODO
83
+
84
+ See [TODO.md](https://github.com/ohmori7/md2slides/blob/main/TODO.md "TODO.md")
85
+
83
86
  ## Development
84
87
 
85
88
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/exe/md2slides CHANGED
@@ -3,24 +3,37 @@
3
3
 
4
4
  # Copyright (c) 2025 Motoyuki OHMORI All rights reserved.
5
5
 
6
- $:.unshift File.join(File.dirname(File.dirname(File.realpath(__FILE__))), 'lib')
7
-
6
+ require 'optparse'
8
7
  require 'md2slides'
9
8
 
9
+ options = {}
10
+ $opts = OptionParser.new do |opts|
11
+ progname = File.basename(__FILE__)
12
+ opts.banner = <<~EOF
13
+ Usage:
14
+ #{progname} list <MD> [<URL or ID>]
15
+ #{progname} update <MD> [<URL or ID>]
16
+ #{progname} fetch <MD> [<URL or ID>]
17
+ #{progname} audio <MD> [<URL or ID>]
18
+ #{progname} video <MD> [<URL or ID>]
19
+ #{progname} deploy <MD> [<URL or ID>]
20
+ #{progname} -h
21
+ EOF
22
+ opts.on('-h', '--help', 'show usage') { options[:h] = true }
23
+ opts.parse!
24
+ end
25
+
10
26
  def usage(errmsg = nil)
11
27
  puts "ERROR: #{errmsg}" if errmsg
12
28
  puts <<~EOF
13
- Usage:
14
- #{$progname} [-f] <file> [<URL or id>]
15
- #{$progname} -h
16
-
29
+ #{$opts.banner}
17
30
  Description:
18
- generate a Google Presentation file.
31
+ generate a Google presentation file.
19
32
 
20
33
  Argument:
21
34
  -h: output this message.
22
- <file>: a file written in markdown
23
- <URL or id>: a URL or a ID of a presentation
35
+ <MD>: a file written in markdown
36
+ <URL or id>: a URL or a ID of a Google presentation
24
37
 
25
38
  BUGS:
26
39
  only .md is allowed for an input file for now.
@@ -29,35 +42,45 @@ BUGS:
29
42
  exit 1
30
43
  end
31
44
 
32
- v = ARGV.shift
33
- case v
34
- when '-h'
35
- usage
36
- when nil
37
- usage
38
- end
39
-
40
- path = v
45
+ usage if options[:h]
46
+ usage if ARGV.size < 2
47
+ cmd = ARGV.shift
48
+ path = ARGV.shift
41
49
  filename = File.basename(path)
42
50
  if filename =~ /^(.*)\.(md)$/i
43
51
  name, ext = $1, $2
44
52
  else
45
- raise("cannot find the file name extention: #{filename}")
53
+ usage("cannot find the file name extention: #{filename}")
46
54
  end
47
55
 
48
56
  md = MD.new(path)
49
57
  url = md.attributes[:url]
50
58
  if url.nil?
51
59
  url = ARGV.shift
52
- usage("No URL and ID specified in the presentation and the argument") if ARGV.size != 0
60
+ usage("No URL and ID specified in the presentation and the argument") if url.nil?
61
+ usage("extra arguments: #{ARGV.join(", ")}") if ! ARGV.nil
53
62
  else
54
63
  usage("URL or ID duplicatedly specified!!") if ARGV.size != 0
55
64
  end
56
65
 
57
- presentation = Presentation.new(url)
58
- presentation.list
59
- presentation.update(md)
60
- #presentation.stick_out_check
61
- presentation.download
62
- presentation.generate_audio
63
- presentation.generate_video
66
+ presentation = Presentation.new(md)
67
+ case cmd
68
+ when 'list'
69
+ presentation.list
70
+ when 'update'
71
+ presentation.update
72
+ #presentation.stick_out_check
73
+ when 'fetch'
74
+ presentation.download
75
+ when 'audio'
76
+ presentation.generate_audio
77
+ when 'video'
78
+ presentation.generate_video
79
+ when 'deploy'
80
+ presentation.update
81
+ presentation.download
82
+ presentation.generate_audio
83
+ presentation.generate_video
84
+ else
85
+ usage("unknown command: #{cmd}")
86
+ end
@@ -2,9 +2,8 @@ class Presentation
2
2
  # XXX: Google Text to Speech produces at this rate...
3
3
  AUDIO_RATE = 24000
4
4
 
5
- def generate_audio0(i, slide, dir)
6
- print "slide \##{i + 1}: generating audio... "
7
- notes = get_slide_note(slide)
5
+ def generate_audio0(i, notes, dir)
6
+ print "slide \##{i}: generating audio... "
8
7
  path = __data_slide_path(i, '.m4a', dir)
9
8
  if notes
10
9
  opath = __data_slide_path(i, '.mp3', dir)
@@ -45,8 +44,14 @@ class Presentation
45
44
 
46
45
  def generate_audio(dir = nil)
47
46
  dir = __data_path(dir)
48
- @presentation.slides.each_with_index do |slide, i|
49
- generate_audio0(i, slide, dir)
47
+ if @md
48
+ @md.each_with_index do |page, i|
49
+ generate_audio0(i + 1, page.comments, dir)
50
+ end
51
+ else
52
+ @presentation.slides.each_with_index do |slide, i|
53
+ generate_audio0(i + 1, get_slide_note(slide), dir)
54
+ end
50
55
  end
51
56
  end
52
57
  end
data/lib/md2slides/md.rb CHANGED
@@ -3,13 +3,13 @@ class MD
3
3
  include Enumerable
4
4
 
5
5
  class Element
6
- attr_reader :type, :value
7
- def initialize(type, value)
8
- @type, @value = type, value
6
+ attr_reader :type, :value, :attributes
7
+ def initialize(type, value, attributes)
8
+ @type, @value, @attributes = type, value, attributes
9
9
  end
10
10
 
11
11
  def to_s
12
- "#{@type.to_s}: #{@value}"
12
+ "#{@type.to_s}: #{@value} (#{@attributes.map { |k, v| "#{k}: #{v}"}.join(",")})"
13
13
  end
14
14
  end
15
15
 
@@ -18,9 +18,15 @@ class MD
18
18
  @comments = []
19
19
  end
20
20
 
21
- def add(type, value)
21
+ def add(type, value, attributes = nil)
22
22
  return if value.nil? || value.empty?
23
- @elements.push(Element.new(type, value))
23
+ case type.to_s
24
+ when /^h([0-9]+)$/
25
+ n = $1.to_i - 1
26
+ attributes ||= {}
27
+ attributes[:indent] = n
28
+ end
29
+ @elements.push(Element.new(type, value, attributes))
24
30
  end
25
31
 
26
32
  def add_comment(c)
@@ -63,6 +69,8 @@ class MD
63
69
  end
64
70
  end
65
71
 
72
+ include Enumerable
73
+
66
74
  attr_reader :attributes
67
75
 
68
76
  def initialize(path)
@@ -79,8 +87,8 @@ class MD
79
87
  @pages.each(&block)
80
88
  end
81
89
 
82
- def each_with_index(&block)
83
- @pages.each_with_index(&block)
90
+ def size
91
+ @pages.size
84
92
  end
85
93
 
86
94
  def parse_header(text)
@@ -99,8 +107,8 @@ class MD
99
107
  def parse_page(text)
100
108
  @pages << page = Page.new
101
109
  is_in_comment = false
102
- text.each_line do |l|
103
- l.strip!
110
+ text.each_line do |l0|
111
+ l = l0.strip
104
112
  next if l.empty?
105
113
  if is_in_comment
106
114
  if l =~ /(.*) ?-->(.*)$/
@@ -118,7 +126,11 @@ class MD
118
126
  h = "h#{sharps.size}"
119
127
  page.add(h.to_sym, title)
120
128
  when /^[-*] *(.*)$/
121
- page.add(:li, $1)
129
+ s = $1
130
+ if l0 =~ /^( +).*$/
131
+ indent = { indent: $1.size }
132
+ end
133
+ page.add(:li, s, indent)
122
134
  when /^<!-- *(.*)$/
123
135
  l = $1
124
136
  if l =~ /(.*) ?-->(.*)$/
@@ -16,10 +16,12 @@ class Presentation
16
16
  attr_reader :id
17
17
 
18
18
  def self.filename_sanitize(s)
19
- s.gsub(/[\/\\:\*\?"<>\|]/, '')
19
+ s&.gsub(/[\/\\:\*\?"<>\|]/, '')
20
20
  end
21
21
 
22
- def initialize(url = nil)
22
+ def initialize(md = nil)
23
+ @md = md
24
+ url = md&.attributes[:url]
23
25
  if url =~ %r{https://docs.google.com/presentation/d/([^\/ ]+).*$}
24
26
  @id = $1
25
27
  elsif url
@@ -29,7 +31,6 @@ class Presentation
29
31
  @authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
30
32
  json_key_io: File.open(ENV['GOOGLE_APPLICATION_CREDENTIALS']),
31
33
  scope: GOOGLE_OAUTH_SCOPE)
32
- @authorizer.fetch_access_token!
33
34
 
34
35
  @slides_service = Google::Apis::SlidesV1::SlidesService.new
35
36
  @slides_service.client_options.application_name = APPLICATION_NAME
@@ -39,13 +40,9 @@ class Presentation
39
40
  @drive_service.client_options.application_name = APPLICATION_NAME
40
41
  @drive_service.authorization = @authorizer
41
42
 
42
- if @id
43
- @presentation = @slides_service.get_presentation(@id)
44
- end
45
-
46
43
  @requests = []
47
44
 
48
- # XXX: this script always runs by an API user...
45
+ # XXX: this seript always runs by an API user...
49
46
  #people_service = Google::Apis::PeopleV1::PeopleServiceService.new
50
47
  #people_service.authorization = @authorizer
51
48
  #profile = people_service.get_person("people/me", person_fields: "names,emailAddresses")
@@ -54,13 +51,29 @@ class Presentation
54
51
  #puts "#{name}: #{email}"
55
52
  end
56
53
 
54
+ def __get_presentation
55
+ begin
56
+ @authorizer.fetch_access_token!
57
+ @presentation = @slides_service.get_presentation(@id)
58
+ rescue => e
59
+ require 'webrick'
60
+ raise(e, "#{e.message} (#{e.status_code} " +
61
+ "#{WEBrick::HTTPStatus.reason_phrase(e.status_code)})\n" +
62
+ "#{e.full_message}")
63
+ end
64
+ end
65
+
57
66
  def exists?
58
- @id && @presentation
67
+ return false if ! @id
68
+ if @presentation.nil?
69
+ __get_presentation
70
+ end
71
+ !! @presentation
59
72
  end
60
73
 
61
74
  def existence_check
62
75
  if ! exists?
63
- raise("presentation (ID: #{@id})does not exists!!!")
76
+ raise("presentation (ID: #{@id}) does not exists!!!")
64
77
  end
65
78
  end
66
79
 
@@ -141,8 +154,7 @@ class Presentation
141
154
  @slides_service.batch_update_presentation(@id, batch_update_request)
142
155
  @requests = []
143
156
  rescue => e
144
- p e.body
145
- raise e
157
+ raise(e, "#{e.body}\n#{e.full_message}")
146
158
  end
147
159
  @presentation = @slides_service.get_presentation(@id)
148
160
  end
@@ -299,7 +311,7 @@ class Presentation
299
311
  puts "title: #{@presentation.title}"
300
312
  puts "pages: #{@presentation.slides.size}"
301
313
  @presentation.slides.each_with_index do |slide, i|
302
- puts "- Slide \##{i + 1} contains #{slide.page_elements.count} elements."
314
+ puts "- slide \##{i + 1} contains #{slide.page_elements.count} elements."
303
315
  end
304
316
  end
305
317
 
@@ -311,14 +323,14 @@ class Presentation
311
323
  end
312
324
  end
313
325
 
314
- def update(md)
326
+ def update
315
327
  #
316
328
  # XXX: currently, clear all slides...
317
329
  #
318
330
  if exists?
319
331
  clear
320
332
  end
321
- md.each do |page|
333
+ @md.each do |page|
322
334
  if page.title_subtitle_only?
323
335
  layout = 'TITLE'
324
336
  elsif page.title_only?
@@ -334,12 +346,8 @@ class Presentation
334
346
  set_slide_subtitle(slide, page.subtitle)
335
347
  else
336
348
  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
349
+ # calculate the indentation.
350
+ n = (e.attributes&.[](:indent).to_i / 2).to_i
343
351
  "\t" * n + e.value
344
352
  end.join("\n")
345
353
  if texts.size > 0
@@ -357,14 +365,19 @@ class Presentation
357
365
 
358
366
  def __data_path(basedir)
359
367
  basedir = '.' if basedir.nil?
360
- title, subtitle = get_title
368
+ if @md
369
+ title = @md.first.title
370
+ subtitle = @md.first.subtitle
371
+ elsif @presentation
372
+ title, subtitle = get_title
373
+ end
361
374
  title = self.class.filename_sanitize(title)
362
375
  subtitle = self.class.filename_sanitize(subtitle)
363
376
  File.join(basedir, "#{title}-#{subtitle}-#{@id}")
364
377
  end
365
378
 
366
379
  def __data_slide_path(i, ext, basedir = nil)
367
- path = "slide-#{i + 1}#{ext}"
380
+ path = "slide-#{i}#{ext}"
368
381
  if basedir
369
382
  path = File.join(basedir, path)
370
383
  end
@@ -419,7 +432,7 @@ class Presentation
419
432
  File.open(lockfile, File::RDWR|File::CREAT, 0644) do |f|
420
433
  f.flock(File::LOCK_EX | File::LOCK_NB)
421
434
  @presentation.slides.each_with_index do |slide, i|
422
- download_slide(i, slide, dir)
435
+ download_slide(i + 1, slide, dir)
423
436
  end
424
437
  end
425
438
  end
@@ -16,9 +16,14 @@ class Presentation
16
16
  end
17
17
  filename += '.mp3'
18
18
 
19
+ text.strip!
20
+ if text !~ /^<speak>/
21
+ text = "<speak>#{text}</speak>"
22
+ end
23
+
19
24
  response = Google::Cloud::TextToSpeech.new.synthesize_speech(
20
25
  # XXX: I don't know how to create an object of Google::Cloud::TextToSpeech::SynthesisInput...
21
- { text: text },
26
+ { ssml: text },
22
27
  #
23
28
  # Standard is the cheapest.
24
29
  # ja-JP-Standard-A, B female
@@ -1,3 +1,3 @@
1
1
  module Md2slides
2
- VERSION = "0.0.3"
2
+ VERSION = "0.0.5"
3
3
  end
@@ -1,6 +1,9 @@
1
1
  require 'open3'
2
2
 
3
3
  class Presentation
4
+ VIDEO_FRAME_RATE = 1
5
+ VIDEO_ONLY_DURATION = 4
6
+
4
7
  def generate_slide_video(i, dir)
5
8
  img = __data_slide_path(i, '.png', dir)
6
9
  audio = __data_slide_path(i, '.m4a', dir)
@@ -11,11 +14,11 @@ class Presentation
11
14
  timeopt = '-shortest'
12
15
  else
13
16
  audioin = "-f lavfi -i aevalsrc=0"
14
- timeopt = "-vframes 60"
17
+ timeopt = "-vframes #{VIDEO_FRAME_RATE * VIDEO_ONLY_DURATION}"
15
18
  end
16
19
  cmd = <<~CMD
17
20
  ffmpeg -hide_banner -y \
18
- -framerate 15 -loop 1 -i "#{img}" \
21
+ -framerate #{VIDEO_FRAME_RATE} -loop 1 -i "#{img}" \
19
22
  #{audioin} -map 0:v:0 -map 1:a:0 \
20
23
  -c:v libx264 -tune stillimage \
21
24
  -c:a aac -ar #{AUDIO_RATE} -ac 1 \
@@ -29,8 +32,9 @@ class Presentation
29
32
 
30
33
  def generate_video(dir = nil)
31
34
  dir = __data_path(dir)
32
- @presentation.slides.each_with_index do |slide, i|
33
- print "slide \##{i + 1}: generating video..."
35
+ pages = @md&.size || @presentation&.slides&.size.to_i
36
+ for i in 1..pages
37
+ print "slide \##{i}: generating video..."
34
38
  generate_slide_video(i, dir)
35
39
  puts "done"
36
40
  end
@@ -38,7 +42,7 @@ class Presentation
38
42
  print "concatenate video files..."
39
43
  videolist = 'video-list.txt'
40
44
  File.open(File.join(dir, videolist), 'w') do |f|
41
- @presentation.slides.each_with_index do |slide, i|
45
+ for i in 1..pages
42
46
  f.puts("file #{__data_slide_path(i, '.mp4')}")
43
47
  end
44
48
  end
data/lib/md2slides.rb CHANGED
@@ -1,5 +1,3 @@
1
- $:.unshift File.dirname(File.dirname(File.realpath(__FILE__)))
2
-
3
1
  module Md2slides
4
2
  class Error < StandardError; end
5
3
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: md2slides
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Motoyuki OHMORI
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-04-29 00:00:00.000000000 Z
11
+ date: 2025-05-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: googleauth