bookbinder 0.2.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.
- data/README.md +97 -0
- data/Rakefile +12 -0
- data/bin/bookbinder +17 -0
- data/lib/bookbinder/document_proxy.rb +171 -0
- data/lib/bookbinder/file.rb +149 -0
- data/lib/bookbinder/file_system/directory.rb +62 -0
- data/lib/bookbinder/file_system/memory.rb +57 -0
- data/lib/bookbinder/file_system/zip_file.rb +106 -0
- data/lib/bookbinder/file_system.rb +35 -0
- data/lib/bookbinder/media_type.rb +17 -0
- data/lib/bookbinder/operations.rb +59 -0
- data/lib/bookbinder/package/epub.rb +69 -0
- data/lib/bookbinder/package/openbook.rb +33 -0
- data/lib/bookbinder/package.rb +295 -0
- data/lib/bookbinder/transform/epub/audio_overlay.rb +227 -0
- data/lib/bookbinder/transform/epub/audio_soundtrack.rb +73 -0
- data/lib/bookbinder/transform/epub/contributor.rb +11 -0
- data/lib/bookbinder/transform/epub/cover_image.rb +80 -0
- data/lib/bookbinder/transform/epub/cover_page.rb +148 -0
- data/lib/bookbinder/transform/epub/creator.rb +67 -0
- data/lib/bookbinder/transform/epub/description.rb +43 -0
- data/lib/bookbinder/transform/epub/language.rb +29 -0
- data/lib/bookbinder/transform/epub/metadata.rb +140 -0
- data/lib/bookbinder/transform/epub/nav.rb +60 -0
- data/lib/bookbinder/transform/epub/nav_toc.rb +177 -0
- data/lib/bookbinder/transform/epub/ncx.rb +63 -0
- data/lib/bookbinder/transform/epub/ocf.rb +33 -0
- data/lib/bookbinder/transform/epub/opf.rb +22 -0
- data/lib/bookbinder/transform/epub/package_identifier.rb +87 -0
- data/lib/bookbinder/transform/epub/rendition.rb +265 -0
- data/lib/bookbinder/transform/epub/resources.rb +38 -0
- data/lib/bookbinder/transform/epub/spine.rb +79 -0
- data/lib/bookbinder/transform/epub/title.rb +92 -0
- data/lib/bookbinder/transform/epub/version.rb +39 -0
- data/lib/bookbinder/transform/generator.rb +8 -0
- data/lib/bookbinder/transform/openbook/json.rb +15 -0
- data/lib/bookbinder/transform/organizer.rb +41 -0
- data/lib/bookbinder/transform.rb +7 -0
- data/lib/bookbinder/version.rb +5 -0
- data/lib/bookbinder.rb +29 -0
- metadata +131 -0
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'mime/types'
|
2
|
+
|
3
|
+
module Bookbinder::MediaType
|
4
|
+
|
5
|
+
MEDIA_TYPES = {
|
6
|
+
'.ncx' => 'application/x-dtbncx+xml',
|
7
|
+
'.opf' => 'application/oebps-package+xml',
|
8
|
+
'.smil' => 'application/smil+xml'
|
9
|
+
}
|
10
|
+
|
11
|
+
|
12
|
+
def self.of(path)
|
13
|
+
ext = File.extname(path)
|
14
|
+
MEDIA_TYPES[ext] || MIME::Types.of(ext).first.to_s || 'text/plain'
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
class Bookbinder::Operations
|
2
|
+
|
3
|
+
class << self
|
4
|
+
|
5
|
+
# This inspects a path and gives you the package class that
|
6
|
+
# probably can read or write it.
|
7
|
+
#
|
8
|
+
def recognize(path)
|
9
|
+
ObjectSpace.each_object(Class).detect { |klass|
|
10
|
+
klass < Bookbinder::Package && klass.recognize(path)
|
11
|
+
} || raise(Bookbinder::UnknownFormat, path)
|
12
|
+
end
|
13
|
+
|
14
|
+
|
15
|
+
def map(path)
|
16
|
+
JSON.pretty_generate(validate(path).map)
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
def validate(path)
|
21
|
+
pkg_klass = recognize(path)
|
22
|
+
pkg = pkg_klass.read(path)
|
23
|
+
# TODO: emit warnings?
|
24
|
+
pkg
|
25
|
+
end
|
26
|
+
|
27
|
+
|
28
|
+
# This will REPLACE the ebook with itself, after
|
29
|
+
# POSSIBLY LOSSY parsing by Bookbinder. Be careful!
|
30
|
+
# If you want to copy-and-normalize, use convert.
|
31
|
+
#
|
32
|
+
def normalize(path)
|
33
|
+
pkg_klass = recognize(path)
|
34
|
+
pkg = pkg_klass.read(path)
|
35
|
+
pkg.write(path)
|
36
|
+
pkg
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
# This will take the ebook at src_path, map it, then convert it
|
41
|
+
# to the format recognized in dest_path. Be aware that ANYTHING
|
42
|
+
# already at dest_path will be replaced.
|
43
|
+
#
|
44
|
+
def convert(src_path, dest_path)
|
45
|
+
src_klass = recognize(src_path)
|
46
|
+
dest_klass = recognize(dest_path)
|
47
|
+
src_pkg = src_klass.read(src_path)
|
48
|
+
dest_pkg = src_pkg.export(dest_klass)
|
49
|
+
dest_pkg.write(dest_path)
|
50
|
+
[src_pkg, dest_pkg]
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
|
55
|
+
|
56
|
+
|
57
|
+
class Bookbinder::UnknownFormat < RuntimeError; end
|
58
|
+
|
59
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
class Bookbinder::Package::EPUB < Bookbinder::Package
|
2
|
+
|
3
|
+
require_transforms('epub')
|
4
|
+
|
5
|
+
DEFAULT_TRANSFORMS = [
|
6
|
+
Bookbinder::Transform::EPUB_PackageIdentifier,
|
7
|
+
Bookbinder::Transform::EPUB_Title,
|
8
|
+
Bookbinder::Transform::EPUB_Creator,
|
9
|
+
Bookbinder::Transform::EPUB_Contributor,
|
10
|
+
Bookbinder::Transform::EPUB_Language,
|
11
|
+
Bookbinder::Transform::EPUB_CoverImage,
|
12
|
+
Bookbinder::Transform::EPUB_Description,
|
13
|
+
Bookbinder::Transform::EPUB_Version,
|
14
|
+
Bookbinder::Transform::EPUB_Spine,
|
15
|
+
Bookbinder::Transform::EPUB_Resources,
|
16
|
+
Bookbinder::Transform::EPUB_NavToc,
|
17
|
+
Bookbinder::Transform::EPUB_CoverPage,
|
18
|
+
Bookbinder::Transform::EPUB_Rendition,
|
19
|
+
Bookbinder::Transform::EPUB_AudioOverlay,
|
20
|
+
Bookbinder::Transform::Organizer,
|
21
|
+
Bookbinder::Transform::Generator
|
22
|
+
]
|
23
|
+
|
24
|
+
DEFAULT_CONTENT_ROOT = 'EPUB'
|
25
|
+
|
26
|
+
|
27
|
+
def self.recognize(path)
|
28
|
+
return (
|
29
|
+
File.extname(path).downcase == '.epub' ||
|
30
|
+
File.directory?(File.join(path, 'META-INF'))
|
31
|
+
)
|
32
|
+
end
|
33
|
+
|
34
|
+
|
35
|
+
def self.transforms
|
36
|
+
@transforms = DEFAULT_TRANSFORMS
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
def transforms
|
41
|
+
self.class.transforms
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
def make_id(path)
|
46
|
+
path.gsub(/[^\w]/, '-')
|
47
|
+
end
|
48
|
+
|
49
|
+
|
50
|
+
def make_path(href)
|
51
|
+
CGI.unescape(href)
|
52
|
+
end
|
53
|
+
|
54
|
+
|
55
|
+
def make_href(path)
|
56
|
+
CGI.escape(path)
|
57
|
+
end
|
58
|
+
|
59
|
+
|
60
|
+
protected
|
61
|
+
|
62
|
+
# Overriding this Package method to inject EPUB's mimetype file.
|
63
|
+
#
|
64
|
+
def write_to_file_system(dest_file_system)
|
65
|
+
dest_file_system.write('mimetype', 'application/epub+zip')
|
66
|
+
super
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
class Bookbinder::Package::Openbook < Bookbinder::Package
|
2
|
+
|
3
|
+
require_transforms('openbook')
|
4
|
+
|
5
|
+
DEFAULT_CONTENT_ROOT = ''
|
6
|
+
attr_accessor(:mip)
|
7
|
+
|
8
|
+
|
9
|
+
def self.recognize(path)
|
10
|
+
ext = File.extname(path).downcase
|
11
|
+
return (
|
12
|
+
ext == '.openbook' ||
|
13
|
+
File.exists?(File.join(path, 'book.json')) ||
|
14
|
+
(ext.empty? && !File.exists?(path))
|
15
|
+
)
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
def transforms
|
20
|
+
[
|
21
|
+
Bookbinder::Transform::Generator,
|
22
|
+
Bookbinder::Transform::Openbook_JSON
|
23
|
+
]
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
def from_map
|
28
|
+
@mip = duplicate_map(@map)
|
29
|
+
super
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
end
|
@@ -0,0 +1,295 @@
|
|
1
|
+
class Bookbinder::Package
|
2
|
+
|
3
|
+
attr_accessor(:file_system, :map, :content_root, :file_aliases, :options)
|
4
|
+
attr_reader(:warnings)
|
5
|
+
|
6
|
+
|
7
|
+
# In subclasses, return true if you can handle the path.
|
8
|
+
# We'll iterate through all the descendent classes of
|
9
|
+
# Package and return the first matching class.
|
10
|
+
#
|
11
|
+
def self.recognize(path)
|
12
|
+
# IMPLEMENT IN SUBCLASSES
|
13
|
+
end
|
14
|
+
|
15
|
+
|
16
|
+
# This will require() all the .rb files within the given subdirectory
|
17
|
+
# of 'bookbinder/transform'. So, for the EPUB package, you'd just call:
|
18
|
+
#
|
19
|
+
# require_transforms('epub')
|
20
|
+
#
|
21
|
+
# ... to load all the EPUB-specific transforms.
|
22
|
+
#
|
23
|
+
def self.require_transforms(dir)
|
24
|
+
Dir.glob(
|
25
|
+
File.join(File.dirname(__FILE__), 'transform', dir, '*.rb')
|
26
|
+
).each { |rb|
|
27
|
+
require("bookbinder/transform/#{dir}/#{File.basename(rb, '.rb')}")
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
# Creates a new package of this class pointing at the given
|
33
|
+
# path (but not reading from it automatically).
|
34
|
+
#
|
35
|
+
def self.build(path)
|
36
|
+
new.tap { |pkg|
|
37
|
+
pkg.build(path)
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
|
42
|
+
# Creates a new package of this class by reading content
|
43
|
+
# from a path.
|
44
|
+
#
|
45
|
+
def self.read(path)
|
46
|
+
new.tap { |pkg|
|
47
|
+
pkg.read(path)
|
48
|
+
}
|
49
|
+
end
|
50
|
+
|
51
|
+
|
52
|
+
def initialize
|
53
|
+
@content_root = ''
|
54
|
+
@file_aliases = {}
|
55
|
+
@options = {}
|
56
|
+
@files = {}
|
57
|
+
reset_transforms
|
58
|
+
end
|
59
|
+
|
60
|
+
|
61
|
+
# Prepares this package for mapping the contents of
|
62
|
+
# the given path.
|
63
|
+
#
|
64
|
+
def build(path)
|
65
|
+
@map = {}
|
66
|
+
@file_system = file_system_from_path(path)
|
67
|
+
end
|
68
|
+
|
69
|
+
|
70
|
+
# Reads the path and generates a hash "map" of the book,
|
71
|
+
# returning it.
|
72
|
+
#
|
73
|
+
def read(path)
|
74
|
+
build(path)
|
75
|
+
to_map
|
76
|
+
@map
|
77
|
+
end
|
78
|
+
|
79
|
+
|
80
|
+
# Creates a new package of the given class, using
|
81
|
+
# our map and file_system, and returns it.
|
82
|
+
#
|
83
|
+
def export(package_class)
|
84
|
+
package_class.new.import(map, file_system, content_root, options)
|
85
|
+
end
|
86
|
+
|
87
|
+
|
88
|
+
# Copies state: a map, a file-system, and a content root.
|
89
|
+
# That should be enough to be a fully independent package.
|
90
|
+
#
|
91
|
+
def import(map, file_system, content_root, options)
|
92
|
+
@map = duplicate_map(map)
|
93
|
+
@file_system = file_system
|
94
|
+
@content_root = content_root
|
95
|
+
@options = options.clone
|
96
|
+
self
|
97
|
+
end
|
98
|
+
|
99
|
+
|
100
|
+
# Copy all components and resources in the map to the new file system,
|
101
|
+
# then creates a new temporary package based on this one and yields it.
|
102
|
+
#
|
103
|
+
# We create a new temporary package rather than using the current one
|
104
|
+
# so that we can have a different file system (and a different content
|
105
|
+
# root if we want that, and fresh hashes for `files`, `file_aliases`,
|
106
|
+
# etc).
|
107
|
+
#
|
108
|
+
def copy_to(path_or_file_system, content_root)
|
109
|
+
dest_file_system = path_or_file_system
|
110
|
+
if path_or_file_system.kind_of?(String)
|
111
|
+
dest_file_system = file_system_from_path(dest_file_system)
|
112
|
+
end
|
113
|
+
all_book_content.each { |ref|
|
114
|
+
rsrc = file(ref['path'])
|
115
|
+
dest_path = ref['path']
|
116
|
+
if content_root && !content_root.empty?
|
117
|
+
dest_path = File.join(content_root, dest_path)
|
118
|
+
dest_path = File.expand_path(dest_path, '/')[1..-1]
|
119
|
+
end
|
120
|
+
rsrc.copy_to(dest_file_system, dest_path)
|
121
|
+
}
|
122
|
+
self.class.new.tap { |pkg|
|
123
|
+
pkg.import(@map, dest_file_system, content_root, @options)
|
124
|
+
}
|
125
|
+
end
|
126
|
+
|
127
|
+
|
128
|
+
def all_book_content
|
129
|
+
book_content = []
|
130
|
+
book_content += map['cover'].values if map['cover']
|
131
|
+
book_content += map['spine'] if map['spine']
|
132
|
+
book_content += map['resources'] if map['resources']
|
133
|
+
book_content
|
134
|
+
end
|
135
|
+
|
136
|
+
|
137
|
+
# Writes package to path. This is destructive! Anything already
|
138
|
+
# at the path will be replaced.
|
139
|
+
#
|
140
|
+
def write(path)
|
141
|
+
tmp_path = Dir::Tmpname.create(File.basename(path)) { |tmp_path|
|
142
|
+
raise Errno::EEXIST if File.exists?(tmp_path)
|
143
|
+
}
|
144
|
+
dest_fs = file_system_from_path(tmp_path)
|
145
|
+
write_to_file_system(dest_fs)
|
146
|
+
dest_fs.close if dest_fs.respond_to?(:close)
|
147
|
+
FileUtils.rm_r(path) if File.exists?(path)
|
148
|
+
FileUtils.move(tmp_path, path)
|
149
|
+
end
|
150
|
+
|
151
|
+
|
152
|
+
# Returns a file object for the given path within this package --
|
153
|
+
# by default, the path is from this package's content_root.
|
154
|
+
#
|
155
|
+
def file(path, root_path = @content_root)
|
156
|
+
if full_path = file_path(path, root_path)
|
157
|
+
@files[full_path] ||= Bookbinder::File.new(full_path, @file_system)
|
158
|
+
else
|
159
|
+
nil
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
|
164
|
+
# Returns the fully-resolved path to a file within this package,
|
165
|
+
# converting aliases and applying the content_root if appropriate.
|
166
|
+
#
|
167
|
+
def file_path(path, root_path = @content_root)
|
168
|
+
path = file_aliases[path] if file_aliases[path]
|
169
|
+
if path.kind_of?(Symbol)
|
170
|
+
nil
|
171
|
+
else
|
172
|
+
path = File.join(root_path, path) if root_path && !root_path.empty?
|
173
|
+
path = File.expand_path(path, '/')[1..-1]
|
174
|
+
path
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
|
179
|
+
# Yields the file only if it exists.
|
180
|
+
#
|
181
|
+
def if_file(*file_args)
|
182
|
+
f = file(*file_args)
|
183
|
+
yield(f) if f && f.exists?
|
184
|
+
end
|
185
|
+
|
186
|
+
|
187
|
+
# The list of transforms to be applied when mapping or writing
|
188
|
+
# this package.
|
189
|
+
#
|
190
|
+
def transforms
|
191
|
+
# IMPLEMENT IN SUBCLASSES
|
192
|
+
[]
|
193
|
+
end
|
194
|
+
|
195
|
+
|
196
|
+
# This is the default "to_map" behavior, but feel free to
|
197
|
+
# override it in subclasses.
|
198
|
+
#
|
199
|
+
def to_map
|
200
|
+
transforms.each { |transform_class|
|
201
|
+
apply_transform(:to_map, transform_class)
|
202
|
+
}
|
203
|
+
end
|
204
|
+
|
205
|
+
|
206
|
+
# This is the default "from_map" behavior, but feel free to
|
207
|
+
# override it in subclasses.
|
208
|
+
#
|
209
|
+
def from_map
|
210
|
+
transforms.each { |transform_class|
|
211
|
+
apply_transform(:from_map, transform_class)
|
212
|
+
}
|
213
|
+
end
|
214
|
+
|
215
|
+
|
216
|
+
# Applies the given transform by instantiating the class
|
217
|
+
# and invoking the specified method on it. `mthd` should
|
218
|
+
# be either `to_map` or `from_map`.
|
219
|
+
#
|
220
|
+
# Runs all dependencies (that have not already run) first.
|
221
|
+
#
|
222
|
+
def apply_transform(mthd, transform_class)
|
223
|
+
run = @transformed[mthd] ||= []
|
224
|
+
return if run.include?(transform_class)
|
225
|
+
run.push(transform_class)
|
226
|
+
transform = transform_class.new
|
227
|
+
transform.dependencies.each { |dep| apply_transform(mthd, dep) }
|
228
|
+
if transform.respond_to?(mthd)
|
229
|
+
# puts("[TRANSFORM] #{transform_class}##{mthd}")
|
230
|
+
@transform = transform
|
231
|
+
@transform_method = mthd
|
232
|
+
@transform.send(mthd, self)
|
233
|
+
@transform = nil
|
234
|
+
@transform_method = nil
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
|
239
|
+
def reset_transforms
|
240
|
+
@warnings = []
|
241
|
+
@transformed = {}
|
242
|
+
end
|
243
|
+
|
244
|
+
|
245
|
+
def warn(msg)
|
246
|
+
@warnings.push({
|
247
|
+
:transform => @transform.class,
|
248
|
+
:method => @transform_method,
|
249
|
+
:message => msg
|
250
|
+
})
|
251
|
+
nil
|
252
|
+
end
|
253
|
+
|
254
|
+
|
255
|
+
# Any file that is marked 'dirty' (because it has been opened
|
256
|
+
# in 'w' mode) will be written out to its file-system.
|
257
|
+
#
|
258
|
+
def save_open_files
|
259
|
+
@files.each_value { |file| file.save }
|
260
|
+
end
|
261
|
+
|
262
|
+
|
263
|
+
protected
|
264
|
+
|
265
|
+
# Inspects the path string to choose a filesystem.
|
266
|
+
# Override this in subclasses if the logic is inadequate.
|
267
|
+
#
|
268
|
+
def file_system_from_path(path)
|
269
|
+
if File.directory?(path) || File.extname(path).empty?
|
270
|
+
Bookbinder::FileSystem::Directory.new(path)
|
271
|
+
else
|
272
|
+
Bookbinder::FileSystem::ZipFile.new(path)
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
|
277
|
+
# Creates a new package with the default content root for this
|
278
|
+
# package class, and invokes `from_map` on it, which will write
|
279
|
+
# it out to the given file system.
|
280
|
+
def write_to_file_system(dest_file_system)
|
281
|
+
new_content_root = self.class::DEFAULT_CONTENT_ROOT
|
282
|
+
copy_to(dest_file_system, new_content_root).tap { |pkg|
|
283
|
+
# Apply all our transforms.
|
284
|
+
pkg.from_map
|
285
|
+
# Save all the open files.
|
286
|
+
pkg.save_open_files
|
287
|
+
}
|
288
|
+
end
|
289
|
+
|
290
|
+
|
291
|
+
def duplicate_map(src_map)
|
292
|
+
JSON.load(JSON.dump(src_map))
|
293
|
+
end
|
294
|
+
|
295
|
+
end
|
@@ -0,0 +1,227 @@
|
|
1
|
+
# EPUB 3 only. Spec:
|
2
|
+
#
|
3
|
+
# http://www.idpf.org/epub/301/spec/epub-mediaoverlays.html#sec-package-metadata
|
4
|
+
#
|
5
|
+
# Each spine item's manifest item may have a media-overlay attribute, which
|
6
|
+
# is an idref pointing at a SMIL manifest item.
|
7
|
+
#
|
8
|
+
# Other properties to manage:
|
9
|
+
#
|
10
|
+
# <meta property="media:active-class">-epub-media-overlay-active</meta>
|
11
|
+
# <meta property="media:playback-active-class">-epub-media-overlay-playing</meta>
|
12
|
+
# <meta property="media:duration" refines="#ch1_audio">0:32:29</meta>
|
13
|
+
# <meta property="media:duration">1:36:20</meta>
|
14
|
+
# <meta property="media:narrator">Bill Speaker</meta>
|
15
|
+
#
|
16
|
+
# Note: "The Package Document must include the duration of each
|
17
|
+
# Media Overlay as well as of the entire Publication."
|
18
|
+
#
|
19
|
+
class Bookbinder::Transform::EPUB_AudioOverlay < Bookbinder::Transform
|
20
|
+
|
21
|
+
def dependencies
|
22
|
+
[
|
23
|
+
Bookbinder::Transform::EPUB_Metadata,
|
24
|
+
Bookbinder::Transform::EPUB_Resources,
|
25
|
+
Bookbinder::Transform::EPUB_Spine
|
26
|
+
]
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
def to_map(package)
|
31
|
+
meta_to_prop(
|
32
|
+
package,
|
33
|
+
'media:active-class',
|
34
|
+
'audio-overlay-active-class'
|
35
|
+
)
|
36
|
+
meta_to_prop(
|
37
|
+
package,
|
38
|
+
'media:playback-active-class',
|
39
|
+
'audio-overlay-playback-active-class'
|
40
|
+
)
|
41
|
+
book_overlay_duration = meta_to_prop(
|
42
|
+
package,
|
43
|
+
'media:duration',
|
44
|
+
'audio-overlay-duration'
|
45
|
+
) { |hashes|
|
46
|
+
hashes.detect { |hash|
|
47
|
+
unless hash.has_key?('refines')
|
48
|
+
hash['@'] = smil_clock_value_to_seconds(hash['@'])
|
49
|
+
true
|
50
|
+
end
|
51
|
+
}
|
52
|
+
}
|
53
|
+
# TODO: check that book does have a duration if there are any overlays.
|
54
|
+
meta_to_prop(
|
55
|
+
package,
|
56
|
+
'media:narrator',
|
57
|
+
'audio-overlay-narrator'
|
58
|
+
) { |hashes|
|
59
|
+
hashes.detect { |hash| !hash.has_key?('refines') }
|
60
|
+
}
|
61
|
+
# Find each manifest item (SI) with media-overlay attribute:
|
62
|
+
#
|
63
|
+
# - find the map['resources'] item that matches the media-overlay id
|
64
|
+
# - find the map['spine'] item that matches the SI id
|
65
|
+
# - set 'audio-overlay' to the AO path
|
66
|
+
# - set 'audio-overlay-duration' to the refined duration
|
67
|
+
# - set 'audio-overlay-narrator' to the refined narrator
|
68
|
+
#
|
69
|
+
opf_doc = package.file(:opf).document('r')
|
70
|
+
opf_doc.each('opf|manifest > opf|item[media-overlay]') { |cmpt_item|
|
71
|
+
cmpt_id = cmpt_item['id']
|
72
|
+
cmpt = package.map['spine'].detect { |c| c['id'] == cmpt_id }
|
73
|
+
# TODO: nil check cmpt
|
74
|
+
ao_id = cmpt_item['media-overlay']
|
75
|
+
rsrc = package.map['resources'].detect { |r| r['id'] == ao_id }
|
76
|
+
# TODO: nil check rsrc
|
77
|
+
cmpt['audio-overlay'] = rsrc['path']
|
78
|
+
cmpt_overlay_duration = meta_to_prop(
|
79
|
+
package,
|
80
|
+
'media:duration',
|
81
|
+
'audio-overlay-duration',
|
82
|
+
cmpt
|
83
|
+
) { |hashes|
|
84
|
+
hashes.detect { |hash|
|
85
|
+
if hash['refines']['@'] == '#'+ao_id
|
86
|
+
hash['@'] = smil_clock_value_to_seconds(hash['@'])
|
87
|
+
true
|
88
|
+
end
|
89
|
+
}
|
90
|
+
}
|
91
|
+
# TODO: nil check cmpt_overlay_duration
|
92
|
+
meta_to_prop(
|
93
|
+
package,
|
94
|
+
'media:narrator',
|
95
|
+
'audio-overlay-narrator',
|
96
|
+
cmpt
|
97
|
+
) { |hashes|
|
98
|
+
hash['refines']['@'] == '#'+ao_id
|
99
|
+
}
|
100
|
+
}
|
101
|
+
end
|
102
|
+
|
103
|
+
|
104
|
+
# Create a meta tag for:
|
105
|
+
# audio-overlay-active-class => media:active-class
|
106
|
+
# audio-overlay-playback-active-class => media:playback-active-class
|
107
|
+
# audio-overlay-duration => media:duration
|
108
|
+
# audio-overlay-narrator => media:narrator
|
109
|
+
#
|
110
|
+
# For each spine item with a 'media-overlay' key:
|
111
|
+
#
|
112
|
+
# Find the corresponding map resource
|
113
|
+
# Find the corresponding manifest item for component:
|
114
|
+
# - set 'media-overlay' to rsrc['id']
|
115
|
+
# Create a top-level meta tag:
|
116
|
+
# cmpt['audio-overlay-duration'] => meta[property='media:duration'][refines='rsrc["id"]]
|
117
|
+
# Also create a top-level meta tag for media:narrator if cmpt has 'audio-overlay-narrator'
|
118
|
+
#
|
119
|
+
def from_map(package)
|
120
|
+
opf_doc = package.file(:opf).document
|
121
|
+
metadata_tag = opf_doc.find('opf|metadata')
|
122
|
+
prop_to_meta(
|
123
|
+
package.map,
|
124
|
+
metadata_tag,
|
125
|
+
'audio-overlay-active-class',
|
126
|
+
'media:active-class'
|
127
|
+
)
|
128
|
+
prop_to_meta(
|
129
|
+
package.map,
|
130
|
+
metadata_tag,
|
131
|
+
'audio-overlay-playback-active-class',
|
132
|
+
'media:playback-active-class'
|
133
|
+
)
|
134
|
+
prop_to_meta(
|
135
|
+
package.map,
|
136
|
+
metadata_tag,
|
137
|
+
'audio-overlay-duration',
|
138
|
+
'media:duration'
|
139
|
+
)
|
140
|
+
prop_to_meta(
|
141
|
+
package.map,
|
142
|
+
metadata_tag,
|
143
|
+
'audio-overlay-narrator',
|
144
|
+
'media:narrator'
|
145
|
+
)
|
146
|
+
package.map['spine'].each { |cmpt|
|
147
|
+
next unless cmpt['audio-overlay']
|
148
|
+
rsrc = package.map['resources'].detect { |r|
|
149
|
+
r['path'] == cmpt['audio-overlay']
|
150
|
+
}
|
151
|
+
cmpt_manifest_item = opf_doc.find("opf|manifest > opf|item##{cmpt['id']}")
|
152
|
+
cmpt_manifest_item['media-overlay'] = rsrc['id']
|
153
|
+
duration_meta_tag = prop_to_meta(
|
154
|
+
cmpt,
|
155
|
+
metadata_tag,
|
156
|
+
'audio-overlay-duration',
|
157
|
+
'media:duration'
|
158
|
+
)
|
159
|
+
duration_meta_tag['refines'] = '#'+rsrc['id']
|
160
|
+
narrator_meta_tag = prop_to_meta(
|
161
|
+
cmpt,
|
162
|
+
metadata_tag,
|
163
|
+
'audio-overlay-narrator',
|
164
|
+
'media:narrator'
|
165
|
+
)
|
166
|
+
narrator_meta_tag['refines'] = '#'+rsrc['id'] if narrator_meta_tag
|
167
|
+
}
|
168
|
+
end
|
169
|
+
|
170
|
+
|
171
|
+
protected
|
172
|
+
|
173
|
+
def meta_to_prop(package, meta_name, prop_name, target = nil)
|
174
|
+
return unless package.map['metadata']
|
175
|
+
hashes = package.map['metadata'][meta_name]
|
176
|
+
return unless hashes && hashes.any?
|
177
|
+
hash = block_given? ? yield(hashes) : hashes.first
|
178
|
+
if hash
|
179
|
+
hashes.delete(hash)
|
180
|
+
package.map['metadata'].delete(meta_name) if hashes.empty?
|
181
|
+
(target || package.map)[prop_name] = hash['@']
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
|
186
|
+
# 5:34:31.396 = 5 hours, 34 minutes, 31 seconds and 396 milliseconds
|
187
|
+
# 124:59:36 = 124 hours, 59 minutes and 36 seconds
|
188
|
+
# 0:05:01.2 = 5 minutes, 1 second and 200 milliseconds
|
189
|
+
# 0:00:04 = 4 seconds
|
190
|
+
# 09:58 = 9 minutes and 58 seconds
|
191
|
+
# 00:56.78 = 56 seconds and 780 milliseconds
|
192
|
+
# 76.2s = 76.2 seconds = 76 seconds and 200 milliseconds
|
193
|
+
# 7.75h = 7.75 hours = 7 hours and 45 minutes
|
194
|
+
# 13min = 13 minutes
|
195
|
+
# 2345ms = 2345 milliseconds
|
196
|
+
# 12.345 = 12 seconds and 345 milliseconds
|
197
|
+
def smil_clock_value_to_seconds(clock)
|
198
|
+
return clock.to_f if clock.kind_of?(Numeric)
|
199
|
+
clock = clock.to_s
|
200
|
+
if match = clock.match(/(\d+:)?(\d+:)(\d+\.?\d*)/)
|
201
|
+
h = match[1].to_s.to_f
|
202
|
+
m = match[2].to_s.to_f
|
203
|
+
s = match[3].to_s.to_f
|
204
|
+
h*60*60 + m*60 + s
|
205
|
+
elsif match = clock.match(/^(\d+\.?\d*)h$/)
|
206
|
+
match[1].to_f*60*60
|
207
|
+
elsif match = clock.match(/^(\d+\.?\d*)min$/)
|
208
|
+
match[1].to_f*60
|
209
|
+
elsif match = clock.match(/^(\d+\.?\d*)s?$/)
|
210
|
+
clock.to_f
|
211
|
+
elsif match = clock.match(/^(\d+\.?\d*)ms$/)
|
212
|
+
clock.to_f / 1000.0
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
|
217
|
+
def prop_to_meta(scope, metadata_tag, prop_name, meta_name)
|
218
|
+
if scope[prop_name]
|
219
|
+
meta_tag = Nokogiri::XML::Node.new('meta', metadata_tag)
|
220
|
+
meta_tag['property'] = meta_name
|
221
|
+
meta_tag.content = scope[prop_name]
|
222
|
+
metadata_tag.add_child(meta_tag)
|
223
|
+
return meta_tag
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
end
|