md2slides 0.0.0 → 0.0.2

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: c82f7759595882a7bce78e9a46dfd95b91dc784038ad91d7295c62c9d73bd414
4
+ data.tar.gz: 76a42b4e8731d1e9888f6ce8534afa876bbe0ed45cae5507dd8caf414af89141
5
5
  SHA512:
6
- metadata.gz: 1aed59d8f309f1a29f0aa16561428c11a2d4c03d159e16625c3421f4870f615697264f60a52645580c125ea83fd7ed23ec483e59361b43ef68f27a619deac3e1
7
- data.tar.gz: 18de9e2866c8a658f7f8de8bf7f73ae818ce7b4f835f7fa6225e42bf558fc53ab8f13923aa031fed35426716abb05c17937945a52aa867ef4fb66afd6187dcf6
6
+ metadata.gz: b5a792702ae0716d1b0bd3cadb0de3fcfa0bfc798b05cbdb7bd78e67afed69dc1c9c826c1b837fbcad11c5b61b36148f4347d1144a86bcb0d039396c355b0812
7
+ data.tar.gz: f742639e88eff95e7a20af0eb79c6a0f0df19a86c97e6429181a8a1eae2d35262d20e85b67eb3c4de21a663116514c5f373d9bc2eaa6c3bbd5e9df8ed010c3bb
data/.gitignore ADDED
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ #/doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
12
+
13
+ # vi file.
14
+ *.swp
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
6
+
7
+ # Specify your gem's dependencies in md2slides.gemspec
8
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2025, Motoyuki OHMORI <ohmori@tottori-u.ac.jp>
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # md2slides
2
+ *md2slides* generates a Google presentation from a markdown file.
3
+ A video file of a presentation will be also generated, and it may contains narration if supplied.
4
+ Narration can be given as notes in slides, and the audio will be generated using Google text-to-speech API and ffmpeg.
5
+
6
+ ## Quick start
7
+ 1. Generate your Google API credentials and copy it to credentails.json:
8
+ ```
9
+ % cat >> config/credentials.json
10
+ {
11
+ "type": "service_account",
12
+ "project_id": "project-id",
13
+ "private_key_id": "private-key-ID',
14
+ "private_key": "-----BEGIN PRIVATE KEY-----\nhogehoge\n-----END PRIVATE KEY-----\n",
15
+ "client_email": "hogehoge@hogehoge.iam.gserviceaccount.com",
16
+ "client_id": "00000000000000000000",
17
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
18
+ "token_uri": "https://oauth2.googleapis.com/token",
19
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
20
+ "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/CLIENT.iam.gserviceaccount.com"
21
+ }
22
+ ^D (this means CTRL + D)
23
+ ```
24
+ 2. install ffmpeg if necessary (if producing video file).
25
+
26
+ 3. install necessary gems.
27
+ ```
28
+ % bundle install --path vendor/bundle
29
+ ```
30
+
31
+ 4. create an empty Google slide.
32
+
33
+ 5. write a markdown file.
34
+ ```
35
+ cat >> doc/sample.md
36
+ ---
37
+ title: hogehoge
38
+ url: <Google presentation URL>
39
+ ---
40
+ # title of the first title page
41
+ ## sub title or author's name(s)
42
+ <!--
43
+ narration text...
44
+ -->
45
+ ---
46
+ # title of the page
47
+ - hogehoge
48
+ ...
49
+ <!--
50
+ narration text...
51
+ -->
52
+ ^D (this means CTRL + D)
53
+ ```
54
+
55
+ 6. execute.
56
+ ```
57
+ bundle exec bin/md2slides doc/sample.md
58
+ ```
59
+
60
+ 7. the Google presentation is updated, and the video is stored in `data/`.
61
+
62
+ ## Installation
63
+
64
+ Add this line to your application's Gemfile:
65
+
66
+ ```ruby
67
+ gem 'md2slides'
68
+ ```
69
+
70
+ And then execute:
71
+
72
+ $ bundle
73
+
74
+ Or install it yourself as:
75
+
76
+ $ gem install md2slides
77
+
78
+ ## Usage
79
+
80
+ TODO: Write usage instructions here
81
+
82
+ ## Development
83
+
84
+ 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.
85
+
86
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
87
+
88
+ ## Contributing
89
+
90
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ohmori7/md2slides.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/TODO.md ADDED
@@ -0,0 +1,25 @@
1
+ # TODO
2
+ - abstraction of presentation elements
3
+ - PDF output
4
+ - character emphasis
5
+ - indentation
6
+ - itemization
7
+ - enumeration
8
+ - theme
9
+ - page number
10
+ - multi column
11
+ - image
12
+ - table
13
+ - other floating object
14
+ - generate a markdown file from an existing presentation file
15
+ - incremental updates
16
+ - other a presentation format such as Microsoft PowerPoint
17
+ - move to google new API:
18
+ *******************************************************************************
19
+ The google-gax gem is officially end-of-life and will not be updated further.
20
+
21
+ If your app uses the google-gax gem, it likely is using obsolete versions of
22
+ some Google Cloud client library (google-cloud-*) gem that depends on it. We
23
+ recommend updating any such libraries that depend on google-gax. Modern Google
24
+ Cloud client libraries will depend on the gapic-common gem instead.
25
+ *******************************************************************************
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "md2slides"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
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
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/bin/text2mp3 ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ # coding: utf-8
3
+
4
+ # Copyright (c) 2025 Motoyuki OHMORI All rights reserved.
5
+
6
+ $:.unshift File.join(File.dirname(File.realpath(__FILE__)), '..', 'lib')
7
+
8
+ require 'presentation'
9
+
10
+ def usage(errmsg = nil)
11
+ puts "ERROR: #{errmsg}" if errmsg
12
+ puts <<~EOF
13
+ Usage:
14
+ #{$progname} [-f] <file>
15
+ #{$progname} -h
16
+
17
+ Description:
18
+ create a voice from a text using Google Text-To-Speech API.
19
+
20
+ Argument:
21
+ -h: output this message.
22
+
23
+ BUGS:
24
+ only .txt is allowed for an input file for now.
25
+
26
+ EOF
27
+ exit 1
28
+ end
29
+
30
+ v = ARGV.shift
31
+ case v
32
+ when '-h'
33
+ usage
34
+ when nil
35
+ usage
36
+ end
37
+ ifile = v
38
+ filename = Presentation::text_to_speech(File.read(ifile), ifile)
39
+ puts "save to #{filename}"
data/config/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ *
2
+ !config.rb
3
+ !.gitignore
data/config/config.rb ADDED
@@ -0,0 +1,2 @@
1
+ BASEDIR = File.dirname(File.dirname(File.realpath(__FILE__)))
2
+ ENV['GOOGLE_APPLICATION_CREDENTIALS'] = "#{BASEDIR}/config/credentials.json"
@@ -0,0 +1,52 @@
1
+ class Presentation
2
+ # XXX: Google Text to Speech produces at this rate...
3
+ AUDIO_RATE = 24000
4
+
5
+ def generate_audio0(i, slide, dir)
6
+ print "slide \##{i + 1}: generating audio... "
7
+ notes = get_slide_note(slide)
8
+ path = __data_slide_path(i, '.m4a', dir)
9
+ if notes
10
+ opath = __data_slide_path(i, '.mp3', dir)
11
+ opath = Presentation::text_to_speech(notes, opath)
12
+ #
13
+ # convert to .m4a, which contains duration in meta data.
14
+ # this prevents unreasonable audio duration when combining
15
+ # audio and video.
16
+ #
17
+ heading_silence = 2
18
+ trailing_silence = 1
19
+ cmd = <<~CMD
20
+ ffmpeg -hide_banner -y \
21
+ -f lavfi -t #{heading_silence} \
22
+ -i anullsrc=r=#{AUDIO_RATE}:cl=mono \
23
+ -i #{opath} \
24
+ -f lavfi -t #{trailing_silence} \
25
+ -i anullsrc=r=#{AUDIO_RATE}:cl=mono \
26
+ -filter_complex "[0:a][1:a][2:a]concat=n=3:v=0:a=1[out]" \
27
+ -map "[out]" \
28
+ -c:a aac -b:a 64k #{path}
29
+ CMD
30
+ msg, errmsg, status = Open3.capture3(cmd)
31
+ File.delete(opath) rescue
32
+ if ! status.success?
33
+ raise("ERROR: cannot convert audio: #{errmsg}")
34
+ end
35
+ puts 'done'
36
+ else
37
+ begin
38
+ File.delete(path)
39
+ rescue Errno::ENOENT => e
40
+ # okay
41
+ end
42
+ puts "skip (no notes)"
43
+ end
44
+ end
45
+
46
+ def generate_audio(dir = nil)
47
+ dir = __data_path(dir)
48
+ @presentation.slides.each_with_index do |slide, i|
49
+ generate_audio0(i, slide, dir)
50
+ end
51
+ end
52
+ 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,3 @@
1
+ module Md2slides
2
+ VERSION = "0.0.2"
3
+ end
@@ -0,0 +1,58 @@
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
+ if File.exists?(audio)
10
+ audioin = "-i \"#{audio}\""
11
+ timeopt = '-shortest'
12
+ else
13
+ audioin = "-f lavfi -i aevalsrc=0"
14
+ timeopt = "-vframes 60"
15
+ end
16
+ cmd = <<~CMD
17
+ ffmpeg -hide_banner -y \
18
+ -framerate 15 -loop 1 -i "#{img}" \
19
+ #{audioin} -map 0:v:0 -map 1:a:0 \
20
+ -c:v libx264 -tune stillimage \
21
+ -c:a aac -ar #{AUDIO_RATE} -ac 1 \
22
+ -pix_fmt yuv420p #{timeopt} "#{video}"
23
+ CMD
24
+ msg, errmsg, status = Open3.capture3(cmd)
25
+ if ! status.success?
26
+ raise("ERROR: cannot produce video: #{errmsg}")
27
+ end
28
+ end
29
+
30
+ def generate_video(dir = nil)
31
+ dir = __data_path(dir)
32
+ @presentation.slides.each_with_index do |slide, i|
33
+ print "slide \##{i + 1}: generating video..."
34
+ generate_slide_video(i, dir)
35
+ puts "done"
36
+ end
37
+
38
+ print "concatenate video files..."
39
+ videolist = File.join(dir, 'video-list.txt')
40
+ File.open(videolist, 'w') do |f|
41
+ @presentation.slides.each_with_index do |slide, i|
42
+ f.puts("file #{__data_slide_path(i, '.mp4')}")
43
+ end
44
+ end
45
+ video = File.join(dir, 'video.mp4')
46
+ cmd = <<~CMD
47
+ cd "#{dir}" && \
48
+ ffmpeg -hide_banner -y -f concat -safe 0 \
49
+ -i "#{videolist}" -c copy "#{video}"
50
+ CMD
51
+ msg, errmsg, status = Open3.capture3(cmd)
52
+ if ! status.success?
53
+ raise("ERROR: cannot produce video: #{errmsg}")
54
+ else
55
+ puts 'done'
56
+ end
57
+ end
58
+ end
data/lib/md2slides.rb ADDED
@@ -0,0 +1,13 @@
1
+ $:.unshift File.dirname(File.dirname(File.realpath(__FILE__)))
2
+
3
+ module Md2slides
4
+ class Error < StandardError; end
5
+
6
+ require 'config/config'
7
+ require 'md2slides/md'
8
+ require 'md2slides/presentation'
9
+ require 'md2slides/text_to_speech'
10
+ require 'md2slides/audio'
11
+ require 'md2slides/video'
12
+ require "md2slides/version"
13
+ end
data/md2slides.gemspec ADDED
@@ -0,0 +1,47 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "md2slides/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "md2slides"
8
+ spec.version = Md2slides::VERSION
9
+ spec.authors = ["Motoyuki OHMORI"]
10
+ spec.email = ["ohmori@tottori-u.ac.jp"]
11
+
12
+ spec.summary = %q{Markdown to presentation slides in ruby.}
13
+ spec.description = %q{Generate Google slides and its video file from a markdown file.}
14
+ spec.homepage = "https://github.com/ohmori7/md2slides"
15
+
16
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
17
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
18
+ if spec.respond_to?(:metadata)
19
+ # spec.metadata["allowed_push_host"] = spec.homepage
20
+
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = spec.homepage
23
+ spec.metadata["changelog_uri"] = spec.homepage
24
+ else
25
+ raise "RubyGems 2.0 or newer is required to protect against " \
26
+ "public gem pushes."
27
+ end
28
+
29
+ # Specify which files should be added to the gem when it is released.
30
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
31
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
32
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
33
+ end
34
+ spec.bindir = "exe"
35
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
36
+ spec.require_paths = ["lib"]
37
+
38
+ spec.add_development_dependency "googleauth"
39
+ # spec.add_development_dependency "google-apis-people_v1"
40
+ spec.add_development_dependency "google-apis-slides_v1"
41
+ spec.add_development_dependency "google-apis-drive_v3"
42
+ spec.add_development_dependency "google-cloud-text_to_speech", "~>0.7.0"
43
+ spec.add_development_dependency "bundler", "~> 1.17"
44
+ spec.add_development_dependency "rake", "~> 10.0"
45
+ spec.add_development_dependency "rspec", "~> 3.0"
46
+ spec.license = "MIT"
47
+ end
@@ -0,0 +1,2 @@
1
+ *
2
+ !.gitignore
metadata CHANGED
@@ -1,27 +1,149 @@
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Motoyuki OHMORI
8
8
  autorequire:
9
- bindir: bin
9
+ bindir: exe
10
10
  cert_chain: []
11
11
  date: 2025-04-29 00:00:00.000000000 Z
12
- dependencies: []
13
- description: Markdown to presentation slides in ruby
14
- email: ohmori@tottori-u.ac.jp
15
- executables:
16
- - md2slides
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: googleauth
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: google-apis-slides_v1
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: google-apis-drive_v3
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: google-cloud-text_to_speech
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.7.0
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.7.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: bundler
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.17'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.17'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '10.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '10.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.0'
111
+ description: Generate Google slides and its video file from a markdown file.
112
+ email:
113
+ - ohmori@tottori-u.ac.jp
114
+ executables: []
17
115
  extensions: []
18
116
  extra_rdoc_files: []
19
117
  files:
118
+ - ".gitignore"
119
+ - ".rspec"
120
+ - Gemfile
121
+ - LICENSE
122
+ - README.md
123
+ - Rakefile
124
+ - TODO.md
125
+ - bin/console
20
126
  - bin/md2slides
127
+ - bin/setup
128
+ - bin/text2mp3
129
+ - config/.gitignore
130
+ - config/config.rb
131
+ - lib/md2slides.rb
132
+ - lib/md2slides/audio.rb
133
+ - lib/md2slides/md.rb
134
+ - lib/md2slides/presentation.rb
135
+ - lib/md2slides/text_to_speech.rb
136
+ - lib/md2slides/version.rb
137
+ - lib/md2slides/video.rb
138
+ - md2slides.gemspec
139
+ - vendor/bundle/.gitignore
21
140
  homepage: https://github.com/ohmori7/md2slides
22
141
  licenses:
23
142
  - MIT
24
- metadata: {}
143
+ metadata:
144
+ homepage_uri: https://github.com/ohmori7/md2slides
145
+ source_code_uri: https://github.com/ohmori7/md2slides
146
+ changelog_uri: https://github.com/ohmori7/md2slides
25
147
  post_install_message:
26
148
  rdoc_options: []
27
149
  require_paths:
@@ -40,5 +162,5 @@ requirements: []
40
162
  rubygems_version: 3.0.3.1
41
163
  signing_key:
42
164
  specification_version: 4
43
- summary: Markdown to presentation slides in ruby
165
+ summary: Markdown to presentation slides in ruby.
44
166
  test_files: []