bookbinder 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/README.md +97 -0
  2. data/Rakefile +12 -0
  3. data/bin/bookbinder +17 -0
  4. data/lib/bookbinder/document_proxy.rb +171 -0
  5. data/lib/bookbinder/file.rb +149 -0
  6. data/lib/bookbinder/file_system/directory.rb +62 -0
  7. data/lib/bookbinder/file_system/memory.rb +57 -0
  8. data/lib/bookbinder/file_system/zip_file.rb +106 -0
  9. data/lib/bookbinder/file_system.rb +35 -0
  10. data/lib/bookbinder/media_type.rb +17 -0
  11. data/lib/bookbinder/operations.rb +59 -0
  12. data/lib/bookbinder/package/epub.rb +69 -0
  13. data/lib/bookbinder/package/openbook.rb +33 -0
  14. data/lib/bookbinder/package.rb +295 -0
  15. data/lib/bookbinder/transform/epub/audio_overlay.rb +227 -0
  16. data/lib/bookbinder/transform/epub/audio_soundtrack.rb +73 -0
  17. data/lib/bookbinder/transform/epub/contributor.rb +11 -0
  18. data/lib/bookbinder/transform/epub/cover_image.rb +80 -0
  19. data/lib/bookbinder/transform/epub/cover_page.rb +148 -0
  20. data/lib/bookbinder/transform/epub/creator.rb +67 -0
  21. data/lib/bookbinder/transform/epub/description.rb +43 -0
  22. data/lib/bookbinder/transform/epub/language.rb +29 -0
  23. data/lib/bookbinder/transform/epub/metadata.rb +140 -0
  24. data/lib/bookbinder/transform/epub/nav.rb +60 -0
  25. data/lib/bookbinder/transform/epub/nav_toc.rb +177 -0
  26. data/lib/bookbinder/transform/epub/ncx.rb +63 -0
  27. data/lib/bookbinder/transform/epub/ocf.rb +33 -0
  28. data/lib/bookbinder/transform/epub/opf.rb +22 -0
  29. data/lib/bookbinder/transform/epub/package_identifier.rb +87 -0
  30. data/lib/bookbinder/transform/epub/rendition.rb +265 -0
  31. data/lib/bookbinder/transform/epub/resources.rb +38 -0
  32. data/lib/bookbinder/transform/epub/spine.rb +79 -0
  33. data/lib/bookbinder/transform/epub/title.rb +92 -0
  34. data/lib/bookbinder/transform/epub/version.rb +39 -0
  35. data/lib/bookbinder/transform/generator.rb +8 -0
  36. data/lib/bookbinder/transform/openbook/json.rb +15 -0
  37. data/lib/bookbinder/transform/organizer.rb +41 -0
  38. data/lib/bookbinder/transform.rb +7 -0
  39. data/lib/bookbinder/version.rb +5 -0
  40. data/lib/bookbinder.rb +29 -0
  41. 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