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.
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