epub-maker 0.0.1
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 +7 -0
- data/.gitignore +19 -0
- data/CHANGELOG.markdown +4 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +22 -0
- data/README.markdown +154 -0
- data/Rakefile +11 -0
- data/bin/epub-maker +3 -0
- data/epub-maker.gemspec +41 -0
- data/lib/epub/maker.rb +113 -0
- data/lib/epub/maker/content_document.rb +36 -0
- data/lib/epub/maker/ocf.rb +60 -0
- data/lib/epub/maker/publication.rb +340 -0
- data/lib/epub/maker/task.rb +134 -0
- data/lib/epub/maker/version.rb +5 -0
- data/test/fixtures/book/META-INF/container.xml +6 -0
- data/test/fixtures/book/OPS/impl.xhtml +9 -0
- data/test/fixtures/book/OPS/item-1.xhtml +8 -0
- data/test/fixtures/book/OPS/item-2.xhtml +12 -0
- data/test/fixtures/book/OPS/nav.xhtml +26 -0
- data/test/fixtures/book/OPS/slideshow.xml +0 -0
- data/test/fixtures/book/OPS//343/203/253/343/203/274/343/203/210/343/203/225/343/202/241/343/202/244/343/203/253.opf +91 -0
- data/test/helper.rb +17 -0
- data/test/make_task.rake +24 -0
- data/test/schemas/epub-nav-30.rnc +10 -0
- data/test/schemas/epub-nav-30.sch +72 -0
- data/test/schemas/epub-xhtml-30.sch +377 -0
- data/test/schemas/ocf-container-30.rnc +16 -0
- data/test/test_inplace_editing.rb +62 -0
- data/test/test_maker.rb +66 -0
- data/test/test_maker_ocf.rb +61 -0
- data/test/test_maker_publication.rb +40 -0
- data/test/test_task.rb +56 -0
- metadata +333 -0
@@ -0,0 +1,340 @@
|
|
1
|
+
require 'epub/publication/package'
|
2
|
+
|
3
|
+
module EPUB
|
4
|
+
module Publication
|
5
|
+
class Package
|
6
|
+
def to_xml(options={:encoding => 'UTF-8'})
|
7
|
+
Nokogiri::XML::Builder.new(options) {|xml|
|
8
|
+
attrs = {
|
9
|
+
'version' => '3.0',
|
10
|
+
'xmlns' => EPUB::NAMESPACES['opf'],
|
11
|
+
'unique-identifier' => unique_identifier.id
|
12
|
+
}
|
13
|
+
[
|
14
|
+
['dir', dir],
|
15
|
+
['id', id],
|
16
|
+
['xml:lang', xml_lang],
|
17
|
+
['prefix', prefix.reduce('') {|attr, (pfx, iri)| [attr, [pfx, iri].join(':')].join(' ')}]
|
18
|
+
].each do |(name, value)|
|
19
|
+
next if value.nil? or value.empty?
|
20
|
+
attrs[name] = value
|
21
|
+
end
|
22
|
+
xml.package_(attrs) do
|
23
|
+
(EPUB::Publication::Package::CONTENT_MODELS - [:bindings, :guide]).each do |model|
|
24
|
+
__send__(model).to_xml_fragment xml
|
25
|
+
end
|
26
|
+
if bindings and !bindings.media_types.empty?
|
27
|
+
bindings.to_xml_fragment xml
|
28
|
+
end
|
29
|
+
end
|
30
|
+
}.to_xml
|
31
|
+
end
|
32
|
+
|
33
|
+
def make
|
34
|
+
(CONTENT_MODELS - [:bindings, :guide]).each do |model|
|
35
|
+
klass = self.class.const_get(model.to_s.capitalize)
|
36
|
+
obj = klass.new
|
37
|
+
__send__ "#{model}=", obj
|
38
|
+
end
|
39
|
+
yield self if block_given?
|
40
|
+
self
|
41
|
+
end
|
42
|
+
|
43
|
+
def make_metadata
|
44
|
+
self.metadata = Metadata.new
|
45
|
+
metadata.make do
|
46
|
+
yield metadata if block_given?
|
47
|
+
end
|
48
|
+
metadata
|
49
|
+
end
|
50
|
+
|
51
|
+
def make_manifest
|
52
|
+
self.manifest = Manifest.new
|
53
|
+
manifest.make do
|
54
|
+
yield manifest if block_given?
|
55
|
+
end
|
56
|
+
manifest
|
57
|
+
end
|
58
|
+
|
59
|
+
def make_spine
|
60
|
+
self.spine = Spine.new
|
61
|
+
spine.make do
|
62
|
+
yield spine if block_given?
|
63
|
+
end
|
64
|
+
spine
|
65
|
+
end
|
66
|
+
|
67
|
+
def make_bindings
|
68
|
+
self.bindings = Bindings.new
|
69
|
+
bindings.make do
|
70
|
+
yield bindings if block_given?
|
71
|
+
end
|
72
|
+
bindings
|
73
|
+
end
|
74
|
+
|
75
|
+
def save(archive)
|
76
|
+
archive.add_buffer book.rootfile_path, to_xml
|
77
|
+
end
|
78
|
+
|
79
|
+
module ContentModel
|
80
|
+
# @param [Nokogiri::XML::Builder::NodeBuilder] node
|
81
|
+
# @param [Object] model
|
82
|
+
# @param [Array<Symbol|String>] attributes names of attribute.
|
83
|
+
def to_xml_attribute(node, model, attributes)
|
84
|
+
attributes.each do |attr|
|
85
|
+
val = model.__send__(attr)
|
86
|
+
node[attr.to_s.gsub('_', '-')] = val if val
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
class Metadata
|
92
|
+
include ContentModel
|
93
|
+
|
94
|
+
def make
|
95
|
+
yield self if block_given?
|
96
|
+
unless unique_identifier
|
97
|
+
if identifiers.empty?
|
98
|
+
identifier = DCMES.new
|
99
|
+
identifier.id = 'pub-id'
|
100
|
+
identifier.content = UUID.create.to_s
|
101
|
+
self.dc_identifiers << identifier
|
102
|
+
self.unique_identifier = identifier
|
103
|
+
else
|
104
|
+
self.unique_identifier = identifiers.first
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
unless metas.any? {|meta| meta.property == 'dcterms:modified'}
|
109
|
+
modified = Meta.new
|
110
|
+
modified.property = 'dcterms:modified'
|
111
|
+
# modified.content = Time.now.utc.strftime('%FT%TZ')
|
112
|
+
modified.content = Time.now.utc.iso8601
|
113
|
+
self.metas << modified
|
114
|
+
end
|
115
|
+
|
116
|
+
self
|
117
|
+
end
|
118
|
+
|
119
|
+
def to_xml_fragment(xml)
|
120
|
+
xml.metadata_('xmlns:dc' => EPUB::NAMESPACES['dc']) {
|
121
|
+
(DC_ELEMS - [:languages]).each do |elems|
|
122
|
+
singular = elems[0..-2] + '_'
|
123
|
+
__send__("dc_#{elems}").each do |elem|
|
124
|
+
node = xml['dc'].__send__(singular, elem.content)
|
125
|
+
to_xml_attribute node, elem, [:id, :dir]
|
126
|
+
node['xml:lang'] = elem.lang if elem.lang
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
languages.each do |language|
|
131
|
+
xml['dc'].language language.content
|
132
|
+
end
|
133
|
+
|
134
|
+
metas.each do |meta|
|
135
|
+
node = xml.meta(meta.content)
|
136
|
+
to_xml_attribute node, meta, [:property, :id, :scheme]
|
137
|
+
node['refines'] = "##{meta.refines.id}" if meta.refines
|
138
|
+
end
|
139
|
+
|
140
|
+
links.each do |link|
|
141
|
+
node = xml.link
|
142
|
+
to_xml_attribute node, link, [:href, :id, :media_type]
|
143
|
+
node['rel'] = link.rel.join(' ') if link.rel
|
144
|
+
node['refines'] = "##{link.refines.id}" if link.refines
|
145
|
+
end
|
146
|
+
}
|
147
|
+
end
|
148
|
+
|
149
|
+
# Shortcut to set title from String
|
150
|
+
# @param title [String]
|
151
|
+
def title=(title)
|
152
|
+
t = Title.new
|
153
|
+
t.content = title
|
154
|
+
self.dc_titles = [t]
|
155
|
+
title
|
156
|
+
end
|
157
|
+
|
158
|
+
# Shortcut to set language from String
|
159
|
+
# @param lang_code [String]
|
160
|
+
def language=(lang_code)
|
161
|
+
lang = DCMES.new
|
162
|
+
lang.content = lang_code
|
163
|
+
self.dc_languages = [lang]
|
164
|
+
lang_code
|
165
|
+
end
|
166
|
+
|
167
|
+
# Shortcut to set one creator from String
|
168
|
+
# @param name [String]
|
169
|
+
def creator=(name)
|
170
|
+
creator = DCMES.new
|
171
|
+
creator.content = name
|
172
|
+
self.dc_creators = [creator]
|
173
|
+
name
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
class Manifest
|
178
|
+
include ContentModel
|
179
|
+
|
180
|
+
def make
|
181
|
+
yield self if block_given?
|
182
|
+
|
183
|
+
# @todo more careful
|
184
|
+
unless items.any? &:nav?
|
185
|
+
nav = Item.new
|
186
|
+
nav.id = 'toc'
|
187
|
+
nav.href = Addressable::URI.parse('nav.xhtml')
|
188
|
+
nav.media_type = 'application/xhtml+xml'
|
189
|
+
nav.properties << 'nav'
|
190
|
+
|
191
|
+
nav_doc = ContentDocument::Navigation.new
|
192
|
+
nav_doc.item = nav
|
193
|
+
|
194
|
+
nav_nav = ContentDocument::Navigation::Navigation.new
|
195
|
+
nav_nav.type = ContentDocument::Navigation::Navigation::Type::TOC
|
196
|
+
nav_nav.items = items.select(&:xhtml?).map {|item; nav|
|
197
|
+
nav = ContentDocument::Navigation::Item.new
|
198
|
+
nav.item = item
|
199
|
+
nav.href = item.href
|
200
|
+
nav.text = File.basename(item.href.normalize.request_uri, '.*')
|
201
|
+
nav
|
202
|
+
}
|
203
|
+
nav_doc.navigations << nav_nav
|
204
|
+
|
205
|
+
nav.content = nav_doc.to_xml
|
206
|
+
|
207
|
+
self << nav
|
208
|
+
end
|
209
|
+
|
210
|
+
self
|
211
|
+
end
|
212
|
+
|
213
|
+
def make_item(options={})
|
214
|
+
item = Item.new
|
215
|
+
[:id, :href, :media_type, :properties, :media_overlay].each do |attr|
|
216
|
+
next unless options.key? attr
|
217
|
+
item.__send__ "#{attr}=", options[attr]
|
218
|
+
end
|
219
|
+
item.manifest = self
|
220
|
+
yield item if block_given?
|
221
|
+
self << item
|
222
|
+
item
|
223
|
+
end
|
224
|
+
|
225
|
+
def to_xml_fragment(xml)
|
226
|
+
node = xml.manifest_ {
|
227
|
+
items.each do |item|
|
228
|
+
item_node = xml.item_
|
229
|
+
to_xml_attribute item_node, item, [:id, :href, :media_type, :media_overlay]
|
230
|
+
item_node['properties'] = item.properties.join(' ') unless item.properties.empty?
|
231
|
+
item_node['fallback'] = item.fallback.id if item.fallback
|
232
|
+
end
|
233
|
+
}
|
234
|
+
to_xml_attribute node, self, [:id]
|
235
|
+
end
|
236
|
+
|
237
|
+
class Item
|
238
|
+
attr_accessor :content, :content_file
|
239
|
+
|
240
|
+
# @param archive [Zip::Archive|nil] archive to save content. If nil, open archive in this method
|
241
|
+
# @raise StandardError when no content nor content_file
|
242
|
+
def save(archive=nil)
|
243
|
+
if archive
|
244
|
+
if content
|
245
|
+
archive.add_or_replace_buffer entry_name, content
|
246
|
+
elsif content_file
|
247
|
+
archive.add_or_replace_file entry_name, content_file
|
248
|
+
else
|
249
|
+
raise 'no content nor content_file'
|
250
|
+
end
|
251
|
+
else
|
252
|
+
Zip::Archive.open manifest.package.book.epub_file do |archive|
|
253
|
+
save archive
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
# Save document into EPUB archive when block ended
|
259
|
+
def edit
|
260
|
+
yield if block_given?
|
261
|
+
save
|
262
|
+
end
|
263
|
+
|
264
|
+
# Save document into EPUB archive at end of block
|
265
|
+
# @yield [REXML::Document]
|
266
|
+
def edit_with_rexml
|
267
|
+
require 'rexml/document'
|
268
|
+
doc = REXML::Document.new(read)
|
269
|
+
yield doc if block_given?
|
270
|
+
self.content = doc.to_s
|
271
|
+
save
|
272
|
+
end
|
273
|
+
|
274
|
+
# Save document into EPUB archive at end of block
|
275
|
+
# @yield [Nokgiri::XML::Document]
|
276
|
+
def edit_with_nokogiri
|
277
|
+
doc = Nokogiri.XML(read)
|
278
|
+
yield doc if block_given?
|
279
|
+
self.content = doc.to_xml
|
280
|
+
save
|
281
|
+
end
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
class Spine
|
286
|
+
include ContentModel
|
287
|
+
|
288
|
+
def make
|
289
|
+
yield self if block_given?
|
290
|
+
self
|
291
|
+
end
|
292
|
+
|
293
|
+
def make_itemref
|
294
|
+
itemref = Itemref.new
|
295
|
+
self << itemref
|
296
|
+
yield itemref if block_given?
|
297
|
+
itemref
|
298
|
+
end
|
299
|
+
|
300
|
+
def to_xml_fragment(xml)
|
301
|
+
node = xml.spine_ {
|
302
|
+
itemrefs.each do |itemref|
|
303
|
+
itemref_node = xml.itemref
|
304
|
+
to_xml_attribute itemref_node, itemref, [:idref, :id]
|
305
|
+
itemref_node['linear'] = 'no' unless itemref.linear?
|
306
|
+
itemref_node['properties'] = itemref.properties.join(' ') unless itemref.properties.empty?
|
307
|
+
end
|
308
|
+
}
|
309
|
+
to_xml_attribute node, self, [:id, :toc, :page_progression_direction]
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
class Bindings
|
314
|
+
include ContentModel
|
315
|
+
|
316
|
+
def make
|
317
|
+
yield self if block_given?
|
318
|
+
self
|
319
|
+
end
|
320
|
+
|
321
|
+
def make_media_type
|
322
|
+
media_type = MediaType.new
|
323
|
+
self << media_type
|
324
|
+
yield media_type if block_given?
|
325
|
+
media_type
|
326
|
+
end
|
327
|
+
|
328
|
+
def to_xml_fragment(xml)
|
329
|
+
xml.bindings_ {
|
330
|
+
media_types.each do |media_type|
|
331
|
+
media_type_node = xml.mediaType
|
332
|
+
to_xml_attribute media_type_node, media_type, [:media_type]
|
333
|
+
media_type_node['handler'] = media_type.handler.id if media_type.handler && media_type.handler.id
|
334
|
+
end
|
335
|
+
}
|
336
|
+
end
|
337
|
+
end
|
338
|
+
end
|
339
|
+
end
|
340
|
+
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
require 'rake'
|
2
|
+
require 'rake/tasklib'
|
3
|
+
require 'epub/maker'
|
4
|
+
|
5
|
+
module EPUB
|
6
|
+
module Maker
|
7
|
+
class Task < ::Rake::TaskLib
|
8
|
+
attr_accessor :target, :base_dir, :files, :file_map_proc,
|
9
|
+
:container, :rootfiles, :make_rootfiles, :package_direction, :language,
|
10
|
+
:titles, :contributors,
|
11
|
+
:resources, :navs, :cover_image, :media_types,
|
12
|
+
:spine,
|
13
|
+
:bindings
|
14
|
+
|
15
|
+
# @param name [String] EPUB file name
|
16
|
+
def initialize(name)
|
17
|
+
init name
|
18
|
+
yield self if block_given?
|
19
|
+
define
|
20
|
+
end
|
21
|
+
|
22
|
+
def init(name)
|
23
|
+
@target = :epub
|
24
|
+
@name = name
|
25
|
+
@files = FileList.new
|
26
|
+
@base_dir = Dir.pwd
|
27
|
+
@rootfiles = FileList.new
|
28
|
+
@make_rootfiles = false
|
29
|
+
@package_direction = 'rtl'
|
30
|
+
@language = 'en'
|
31
|
+
@file_map = {}
|
32
|
+
@file_map_proc = -> (src_name) {src_name.sub("#{@base_dir.sub(/\/\z/, '')}/", '')}
|
33
|
+
@navs = FileList.new
|
34
|
+
@media_types = {}
|
35
|
+
@spine = FileList.new
|
36
|
+
@bindings = {}
|
37
|
+
end
|
38
|
+
|
39
|
+
def define
|
40
|
+
desc 'Make EPUB file'
|
41
|
+
task @target do
|
42
|
+
EPUB::Maker.make @name do |book|
|
43
|
+
book.make_ocf do |ocf|
|
44
|
+
if container
|
45
|
+
ocf.container = EPUB::Parser::OCF.new(nil).parse_container(File.read(container))
|
46
|
+
else
|
47
|
+
raise 'Set at least one rootfile' if rootfiles.empty?
|
48
|
+
ocf.make_container do |container|
|
49
|
+
rootfiles.each do |rootfile|
|
50
|
+
container.make_rootfile full_path: Addressable::URI.parse(file_map[rootfile])
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
if make_rootfiles
|
57
|
+
book.make_package do |package|
|
58
|
+
package.dir = package_direction
|
59
|
+
|
60
|
+
package.make_metadata do |metadata|
|
61
|
+
metadata.title = @titles.first
|
62
|
+
metadata.language = language
|
63
|
+
end
|
64
|
+
|
65
|
+
package.make_manifest do |manifest|
|
66
|
+
rootfile_path = Pathname(package.book.rootfile_path)
|
67
|
+
resources.each_with_index do |resource, index|
|
68
|
+
resource_path = Pathname(file_map[resource])
|
69
|
+
manifest.make_item do |item|
|
70
|
+
item.id = "item-#{index + 1}"
|
71
|
+
href = resource_path.relative_path_from(rootfile_path.parent)
|
72
|
+
item.href = Addressable::URI.parse(href.to_path)
|
73
|
+
item.media_type = media_types[resource] ||
|
74
|
+
case resource_path.extname
|
75
|
+
when '.xhtml', '.html' then 'application/xhtml+xml'
|
76
|
+
end
|
77
|
+
item.content_file = resource
|
78
|
+
item.properties << 'nav' if navs.include? item.entry_name
|
79
|
+
item.properties << 'scripted' unless Nokogiri.XML(open(resource)).search('script').empty?
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
package.make_spine do |spine|
|
85
|
+
@spine.each do |item_path|
|
86
|
+
entry_name = file_map[item_path]
|
87
|
+
spine.make_itemref do |itemref|
|
88
|
+
item = package.manifest.items.find {|i| i.entry_name == entry_name}
|
89
|
+
itemref.item = item if item
|
90
|
+
warn "missing item #{item_path}, referred by itemref" if itemref.item.nil?
|
91
|
+
itemref.linear = true # TODO: Make more customizable
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
if @bindings and !@bindings.empty?
|
97
|
+
package.make_bindings do |bindings|
|
98
|
+
@bindings.each_pair do |media_type, handler_path|
|
99
|
+
bindings.make_media_type do |mt|
|
100
|
+
mt.media_type = media_type
|
101
|
+
entry_name = file_map[handler_path]
|
102
|
+
mt.handler = package.manifest.items.find {|item| item.entry_name == entry_name}
|
103
|
+
warn "missing handler for #{media_type}" if mt.handler.nil?
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
else
|
110
|
+
raise 'No rootfile set' if @rootfiles.empty?
|
111
|
+
rf = rootfiles.first
|
112
|
+
book.package = EPUB::Parser::Publication.new(File.read(rf), file_map[rf]).parse
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def rootfile=(rootfile)
|
119
|
+
rootfiles.unshift rootfile
|
120
|
+
rootfile
|
121
|
+
end
|
122
|
+
|
123
|
+
def file_map(force_calculation=false)
|
124
|
+
if force_calculation or @file_map.empty?
|
125
|
+
@file_map.clear
|
126
|
+
@files.each do |src_name|
|
127
|
+
@file_map[src_name] = @file_map_proc[src_name]
|
128
|
+
end
|
129
|
+
end
|
130
|
+
@file_map
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|