epuber 0.7.4 → 0.9.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +10 -2
  3. data/LICENSE.txt +1 -1
  4. data/README.md +4 -3
  5. data/epuber.gemspec +11 -16
  6. data/lib/epuber/book/contributor.rb +10 -7
  7. data/lib/epuber/book/file_request.rb +3 -3
  8. data/lib/epuber/book/target.rb +30 -33
  9. data/lib/epuber/book/toc_item.rb +2 -4
  10. data/lib/epuber/book.rb +21 -21
  11. data/lib/epuber/checker/bookspec_checker.rb +26 -0
  12. data/lib/epuber/checker/text_checker.rb +16 -7
  13. data/lib/epuber/checker.rb +16 -2
  14. data/lib/epuber/checker_transformer_base.rb +3 -6
  15. data/lib/epuber/command/build.rb +40 -25
  16. data/lib/epuber/command/from_file.rb +39 -0
  17. data/lib/epuber/command/init.rb +34 -32
  18. data/lib/epuber/command/server.rb +3 -3
  19. data/lib/epuber/command.rb +18 -20
  20. data/lib/epuber/compiler/compilation_context.rb +10 -8
  21. data/lib/epuber/compiler/file_database.rb +2 -4
  22. data/lib/epuber/compiler/file_finders/abstract.rb +36 -26
  23. data/lib/epuber/compiler/file_finders/imaginary.rb +40 -35
  24. data/lib/epuber/compiler/file_resolver.rb +79 -89
  25. data/lib/epuber/compiler/file_stat.rb +4 -4
  26. data/lib/epuber/compiler/file_types/abstract_file.rb +4 -7
  27. data/lib/epuber/compiler/file_types/bade_file.rb +20 -15
  28. data/lib/epuber/compiler/file_types/coffee_script_file.rb +1 -1
  29. data/lib/epuber/compiler/file_types/css_file.rb +103 -0
  30. data/lib/epuber/compiler/file_types/generated_file.rb +1 -1
  31. data/lib/epuber/compiler/file_types/image_file.rb +4 -2
  32. data/lib/epuber/compiler/file_types/nav_file.rb +0 -1
  33. data/lib/epuber/compiler/file_types/opf_file.rb +0 -1
  34. data/lib/epuber/compiler/file_types/source_file.rb +8 -3
  35. data/lib/epuber/compiler/file_types/stylus_file.rb +4 -3
  36. data/lib/epuber/compiler/file_types/xhtml_file.rb +67 -13
  37. data/lib/epuber/compiler/generator.rb +1 -2
  38. data/lib/epuber/compiler/meta_inf_generator.rb +1 -1
  39. data/lib/epuber/compiler/nav_generator.rb +10 -11
  40. data/lib/epuber/compiler/opf_generator.rb +26 -27
  41. data/lib/epuber/compiler/problem.rb +12 -21
  42. data/lib/epuber/compiler/xhtml_processor.rb +63 -32
  43. data/lib/epuber/compiler.rb +77 -25
  44. data/lib/epuber/config.rb +16 -10
  45. data/lib/epuber/dsl/attribute.rb +17 -18
  46. data/lib/epuber/dsl/attribute_support.rb +7 -7
  47. data/lib/epuber/dsl/object.rb +19 -17
  48. data/lib/epuber/dsl/tree_object.rb +2 -3
  49. data/lib/epuber/epubcheck.rb +15 -0
  50. data/lib/epuber/from_file/bookspec_generator.rb +371 -0
  51. data/lib/epuber/from_file/encryption_handler.rb +146 -0
  52. data/lib/epuber/from_file/from_file_executor.rb +140 -0
  53. data/lib/epuber/from_file/nav_file.rb +163 -0
  54. data/lib/epuber/from_file/opf_file.rb +219 -0
  55. data/lib/epuber/helper.rb +0 -1
  56. data/lib/epuber/lockfile.rb +7 -9
  57. data/lib/epuber/plugin.rb +2 -3
  58. data/lib/epuber/ruby_extensions/match_data.rb +1 -1
  59. data/lib/epuber/ruby_extensions/thread.rb +1 -0
  60. data/lib/epuber/server/base.styl +0 -1
  61. data/lib/epuber/server/basic.styl +1 -30
  62. data/lib/epuber/server/handlers.rb +1 -1
  63. data/lib/epuber/server.rb +81 -80
  64. data/lib/epuber/third_party/bower.rb +5 -5
  65. data/lib/epuber/transformer/book_transformer.rb +108 -0
  66. data/lib/epuber/transformer/text_transformer.rb +4 -2
  67. data/lib/epuber/transformer.rb +4 -2
  68. data/lib/epuber/user_interface.rb +49 -38
  69. data/lib/epuber/vendor/hash_binding.rb +9 -2
  70. data/lib/epuber/vendor/ruby_templater.rb +4 -8
  71. data/lib/epuber/vendor/version.rb +12 -12
  72. data/lib/epuber/version.rb +1 -1
  73. metadata +79 -100
  74. data/lib/epuber/server/fonts/AvenirNext/AvenirNext-Bold.ttf +0 -0
  75. data/lib/epuber/server/fonts/AvenirNext/AvenirNext-BoldItalic.ttf +0 -0
  76. data/lib/epuber/server/fonts/AvenirNext/AvenirNext-Italic.ttf +0 -0
  77. data/lib/epuber/server/fonts/AvenirNext/AvenirNext-Regular.ttf +0 -0
@@ -10,7 +10,7 @@ module Epuber
10
10
  def initialize(parent = nil)
11
11
  super()
12
12
 
13
- @parent = parent
13
+ @parent = parent
14
14
  @sub_items = []
15
15
 
16
16
  parent.sub_items << self unless parent.nil?
@@ -94,8 +94,7 @@ module Epuber
94
94
 
95
95
  protected
96
96
 
97
- attr_writer :parent
98
- attr_writer :sub_items
97
+ attr_writer :parent, :sub_items
99
98
  end
100
99
  end
101
100
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Epuber
4
+ class Epubcheck
5
+ class << self
6
+ # @param [String] path path to file
7
+ #
8
+ def check(path)
9
+ res = system('epubcheck', path)
10
+
11
+ UI.error!('Epubcheck failed') unless res
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Epuber
6
+ class BookspecGenerator
7
+ class TocItem
8
+ # @return [String]
9
+ #
10
+ attr_accessor :href
11
+
12
+ # @return [String, nil]
13
+ #
14
+ attr_accessor :title
15
+
16
+ # @return [Array<TocItem>]
17
+ #
18
+ attr_accessor :children
19
+
20
+ # @return [Array<Symbol>]
21
+ #
22
+ attr_accessor :landmarks
23
+
24
+ def initialize(href, title = nil, landmarks = [], children = [])
25
+ @href = href
26
+ @title = title
27
+ @landmarks = landmarks
28
+ @children = children
29
+ end
30
+
31
+ def attribs
32
+ [href.inspect, title&.inspect, *landmarks.map(&:inspect)].compact.join(', ')
33
+ end
34
+
35
+ def ==(other)
36
+ other.is_a?(TocItem) &&
37
+ href == other.href &&
38
+ title == other.title &&
39
+ landmarks == other.landmarks &&
40
+ children == other.children
41
+ end
42
+
43
+ def to_s(level = 0)
44
+ indent = ' ' * level
45
+ children_str = children.map { |c| c.to_s(level + 2) }.join("\n")
46
+ %(#{indent}#{href.inspect} #{title.inspect} #{landmarks.inspect}\n#{children_str})
47
+ end
48
+
49
+ def inspect
50
+ %(#<#{self.class.name} #{self}>)
51
+ end
52
+ end
53
+
54
+ # @param [Epuber::OpfFile] opf
55
+ # @param [Epuber::NavFile, nil] nav
56
+ #
57
+ def initialize(opf, nav)
58
+ @opf = opf
59
+ @nav = nav
60
+ end
61
+
62
+ # @return [String]
63
+ #
64
+ def generate_bookspec
65
+ @indent = 0
66
+ @bookspec = []
67
+
68
+ add_code('Epuber::Book.new do |book|', after: 'end') do
69
+ generate_titles
70
+ generate_authors
71
+ add_empty_line
72
+ generate_id
73
+ generate_language
74
+ generate_published
75
+ generate_publisher
76
+ add_empty_line
77
+ generate_cover
78
+ add_empty_line
79
+ generate_toc
80
+ end
81
+ add_empty_line
82
+
83
+ @bookspec.join("\n")
84
+ end
85
+
86
+ # @return [void]
87
+ #
88
+ def generate_titles
89
+ titles = @opf.metadata.css('title')
90
+ titles.each do |title|
91
+ is_main = titles.count == 1
92
+
93
+ id = title['id']
94
+ is_main = @opf.find_refines(id, 'title-type') == 'main' if id && !is_main
95
+
96
+ add_comment('alternate title found from original EPUB file (Epuber supports only one title)') unless is_main
97
+ add_setting_property(:title, title.text.inspect, commented: !is_main)
98
+ end
99
+ end
100
+
101
+ # @return [void]
102
+ #
103
+ def generate_authors
104
+ authors = @opf.metadata.css('creator')
105
+ if authors.empty?
106
+ add_setting_property(:author, nil)
107
+ elsif authors.count == 1
108
+ add_setting_property(:author, format_author(authors.first))
109
+ else
110
+ add_setting_property(:authors, authors.map { |auth| format_author(auth) })
111
+ end
112
+ end
113
+
114
+ # @return [String]
115
+ #
116
+ def format_author(author_node)
117
+ name = author_node.text.strip
118
+ id = author_node['id']
119
+
120
+ if id
121
+ role = @opf.find_refines(id, 'role')
122
+ file_as = @opf.find_refines(id, 'file-as')
123
+ end
124
+
125
+ role ||= author_node['opf:role']
126
+ file_as ||= author_node['opf:file-as']
127
+
128
+ role_is_default = role.nil? || role == 'aut'
129
+ file_as_is_default = file_as.nil? || contributor_file_as_eq?(Book::Contributor.from_obj(name).file_as, file_as)
130
+
131
+ if role_is_default && file_as_is_default
132
+ name.inspect
133
+ elsif role_is_default && !file_as_is_default
134
+ %({ pretty_name: #{name.inspect}, file_as: #{file_as.inspect} })
135
+ elsif !role_is_default && file_as_is_default
136
+ %({ name: #{name.inspect}, role: #{role.inspect} })
137
+ else
138
+ %({ pretty_name: #{name.inspect}, file_as: #{file_as.inspect}, role: #{role.inspect} })
139
+ end
140
+ end
141
+
142
+ # @return [void]
143
+ #
144
+ def generate_id
145
+ nodes = @opf.metadata.css('identifier')
146
+
147
+ nodes.each do |id_node|
148
+ value = id_node.text.strip
149
+
150
+ is_main = @opf.package['unique-identifier'] == id_node['id']
151
+ is_isbn = value.start_with?('urn:isbn:')
152
+ value = value.sub(/^urn:isbn:/, '').strip if is_isbn
153
+ key = is_isbn ? :isbn : :identifier
154
+
155
+ if is_main
156
+ add_setting_property(key, value.inspect)
157
+ else
158
+ add_comment('alternate identifier found from original EPUB file (Epuber supports only one identifier)')
159
+ add_setting_property(key, value.inspect, commented: true)
160
+ end
161
+ end
162
+ end
163
+
164
+ # @return [void]
165
+ #
166
+ def generate_language
167
+ language = @opf.metadata.at_css('language')
168
+ add_setting_property(:language, language.text.strip.inspect) if language
169
+ end
170
+
171
+ # @return [void]
172
+ #
173
+ def generate_published
174
+ published = @opf.metadata.at_css('date')
175
+ add_setting_property(:published, published.text.strip.inspect) if published
176
+ end
177
+
178
+ # @return [void]
179
+ #
180
+ def generate_publisher
181
+ publisher = @opf.metadata.at_css('publisher')
182
+ add_setting_property(:publisher, publisher.text.strip.inspect) if publisher
183
+ end
184
+
185
+ # @return [void]
186
+ #
187
+ def generate_cover
188
+ cover_property = Compiler::OPFGenerator::PROPERTIES_MAP[:cover_image]
189
+
190
+ cover_id = @opf.manifest_items.find { |_, item| item.properties&.include?(cover_property) }&.last&.id
191
+ cover_id ||= @opf.metadata.at_css('meta[name="cover"]')&.[]('content')
192
+ cover = @opf.manifest_file_by_id(cover_id) if cover_id
193
+ return unless cover
194
+
195
+ href = cover.href.sub(/#{Regexp.escape(File.extname(cover.href))}$/, '')
196
+ add_setting_property(:cover_image, href.inspect)
197
+ end
198
+
199
+ # @return [void]
200
+ #
201
+ def generate_toc
202
+ items = calculate_toc_items
203
+
204
+ render_toc_item = lambda do |item|
205
+ if item.children.empty?
206
+ add_code(%(toc.file #{item.attribs}))
207
+ else
208
+ add_code(%(toc.file #{item.attribs} do), after: 'end') do
209
+ item.children.each do |child|
210
+ render_toc_item.call(child)
211
+ end
212
+ end
213
+ end
214
+ end
215
+
216
+ add_code('book.toc do |toc, target|', after: 'end') do
217
+ items.each do |toc_item|
218
+ render_toc_item.call(toc_item)
219
+ end
220
+ end
221
+ end
222
+
223
+ private
224
+
225
+ # Add code to final file. When block is given, it will be indented.
226
+ #
227
+ # @param [String] code code to print into final file
228
+ # @param [Boolean] commented should be code commented?
229
+ # @param [String, nil] after code to print after this code
230
+ #
231
+ # @return [void]
232
+ #
233
+ def add_code(code, commented: false, after: nil)
234
+ indent_str = ' ' * @indent
235
+ comment_prefix = commented ? '# ' : ''
236
+
237
+ @bookspec << %(#{indent_str}#{comment_prefix}#{code})
238
+
239
+ if block_given?
240
+ increase_indent
241
+ yield
242
+ decrease_indent
243
+ end
244
+
245
+ add_code(after, commented: commented) if after
246
+ end
247
+
248
+ def add_setting_property(name, value, commented: false)
249
+ if value.is_a?(Array)
250
+ add_code(%(book.#{name} = [), commented: commented, after: ']') do
251
+ value.each do |v|
252
+ add_code(%(#{v},), commented: commented)
253
+ end
254
+ end
255
+ else
256
+ add_code(%(book.#{name} = #{value}), commented: commented)
257
+ end
258
+ end
259
+
260
+ def add_comment(text)
261
+ add_code(text, commented: true)
262
+ end
263
+
264
+ def add_empty_line
265
+ return if @bookspec.last == ''
266
+
267
+ @bookspec << ''
268
+ end
269
+
270
+ def increase_indent
271
+ @indent += 2
272
+ end
273
+
274
+ def decrease_indent
275
+ @indent -= 2
276
+ @indent = 0 if @indent.negative?
277
+ end
278
+
279
+ # Compares two contributors by file_as, it will return true if file_as is same even when case is different
280
+ #
281
+ # @param [String] file_as_a
282
+ # @param [String] file_as_b
283
+ #
284
+ # @return [Boolean]
285
+ #
286
+ def contributor_file_as_eq?(file_as_a, file_as_b)
287
+ file_as_a == file_as_b || file_as_a.mb_chars.downcase == file_as_b.mb_chars.downcase
288
+ end
289
+
290
+ # @param [String] href
291
+ #
292
+ # @return [Array<Symbol>]
293
+ #
294
+ def landmark_for_href(href)
295
+ landmarks = @nav&.landmarks
296
+ landmark_map = NavFile::LANDMARKS_MAP unless landmarks.nil?
297
+
298
+ if landmarks.nil?
299
+ landmarks = @opf.guide_items
300
+ landmark_map = OpfFile::LANDMARKS_MAP
301
+ end
302
+
303
+ found = landmarks.select { |l| l.href == href }
304
+ .map(&:type)
305
+ .compact
306
+ .map { |t| landmark_map[t] }
307
+ .compact
308
+
309
+ return found unless found.empty?
310
+
311
+ # try to find by href without fragment
312
+ if href.include?('#')
313
+ href = href.split('#').first
314
+ return landmark_for_href(href)
315
+ end
316
+
317
+ []
318
+ end
319
+
320
+ # @return [Array<Epuber::BookspecGenerator::TocItem>]
321
+ #
322
+ def calculate_toc_items
323
+ spine_items = @opf.spine_items
324
+ idrefs = spine_items.map(&:idref)
325
+ landmarks = Set.new
326
+
327
+ # @param [ManifestItem, nil] manifest_item
328
+ # @param [NavItem, nil] toc_item
329
+ #
330
+ # @return [Array<Epuber::BookspecGenerator::TocItem>]
331
+ #
332
+ render_toc_item = lambda do |manifest_item, toc_item|
333
+ # ignore this item when it was already rendered
334
+ next nil if manifest_item && !idrefs.include?(manifest_item.id)
335
+
336
+ manifest_item ||= @opf.manifest_file_by_href(toc_item.href)
337
+
338
+ href = toc_item&.href || manifest_item.href
339
+ toc_item ||= @nav&.find_by_href(href) || @nav&.find_by_href(href, ignore_fragment: true)
340
+ href = toc_item&.href || manifest_item.href
341
+
342
+ href_output = href.sub(/\.x?html$/, '')
343
+ .sub(/\.x?html#/, '#')
344
+
345
+ landmarks_for_this = landmark_for_href(href)
346
+ unless landmarks_for_this.empty?
347
+ diff = Set.new(landmarks_for_this) - landmarks
348
+ landmarks += diff
349
+
350
+ landmarks_for_this = diff.to_a
351
+ end
352
+
353
+ item = TocItem.new(href_output, toc_item&.title, landmarks_for_this)
354
+ item.children = toc_item&.children&.map do |child|
355
+ render_toc_item.call(nil, child)
356
+ end || []
357
+
358
+ idrefs.delete(manifest_item.id) if manifest_item
359
+
360
+ item
361
+ end
362
+
363
+ spine_items.map do |itemref|
364
+ idref = itemref.idref
365
+ next unless idref
366
+
367
+ render_toc_item.call(@opf.manifest_file_by_id(idref), nil)
368
+ end.compact
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+ require 'uuidtools'
5
+ require 'digest'
6
+
7
+ module Epuber
8
+ class EncryptionHandler
9
+ class EncryptionItem
10
+ # Encryption algorithm (probably EncryptionHandler::ADOBE_OBFUSCATION or EncryptionHandler::IDPF_OBFUSCATION)
11
+ #
12
+ # @return [String]
13
+ #
14
+ attr_accessor :algorithm
15
+
16
+ # Absolute path to file (from root of EPUB)
17
+ #
18
+ # @return [String]
19
+ #
20
+ attr_accessor :file_path
21
+
22
+ # Encryption key for this file
23
+ #
24
+ # @return [String, nil]
25
+ #
26
+ attr_accessor :key
27
+
28
+ # @param [String] algorithm
29
+ # @param [String] file_path
30
+ #
31
+ def initialize(algorithm, file_path)
32
+ @algorithm = algorithm
33
+ @file_path = file_path
34
+ end
35
+ end
36
+
37
+ ADOBE_OBFUSCATION = 'http://ns.adobe.com/pdf/enc#RC'
38
+ IDPF_OBFUSCATION = 'http://www.idpf.org/2008/embedding'
39
+
40
+ # @return [Hash<String, EncryptionItem>] key is abs file path (from root of EPUB), value is EncryptionItem
41
+ #
42
+ attr_reader :encryption_items
43
+
44
+ # @param [String] encryption_file contents of META-INF/encryption.xml file
45
+ # @param [Epuber::OpfFile] opf
46
+ #
47
+ def initialize(encryption_file, opf)
48
+ @opf = opf
49
+ @encryption_items = _prepare_items(encryption_file)
50
+ end
51
+
52
+ # @param [String] path
53
+ # @param [String] data
54
+ def process_file(path, data)
55
+ enc_item = @encryption_items[path]
56
+ data = EncryptionHandler.decrypt_data(enc_item.key, data, enc_item.algorithm) if enc_item
57
+
58
+ data
59
+ end
60
+
61
+ # Decrypt data with given key and algorithm
62
+ #
63
+ # @param [String] key
64
+ # @param [String] data
65
+ # @param [String] algorithm
66
+ #
67
+ def self.decrypt_data(key, data, algorithm)
68
+ is_adobe = algorithm == ADOBE_OBFUSCATION
69
+ crypt_len = is_adobe ? 1024 : 1040
70
+ crypt = data.byteslice(0, crypt_len)
71
+ .bytes
72
+ key_cycle = key.bytes
73
+ .cycle
74
+ decrypt = crypt.each_with_object([]) { |x, acc| acc << (x ^ key_cycle.next) }
75
+ .pack('C*')
76
+ decrypt + data.byteslice(crypt_len..-1)
77
+ end
78
+
79
+ # Parse IDPF key from unique identifier (main identifier from OPF file)
80
+ #
81
+ # @param [String] raw_unique_identifier
82
+ #
83
+ # @return [String, nil]
84
+ #
85
+ def self.parse_idpf_key(raw_unique_identifier)
86
+ key = raw_unique_identifier.strip.gsub(/[\u0020\u0009\u000d\u000a]/, '')
87
+ Digest::SHA1.digest(key)
88
+ end
89
+
90
+ # @param [String] raw_unique_identifier
91
+ # @param [Array<Nokogiri::XML::Node>] identifiers
92
+ #
93
+ # @return [String, nil]
94
+ #
95
+ def self.find_and_parse_encryption_key(identifiers)
96
+ raw_identifier = identifiers.find do |i|
97
+ i['scheme']&.downcase == 'uuid' || i.text.strip.start_with?('urn:uuid:')
98
+ end&.text&.strip
99
+ return nil unless raw_identifier
100
+
101
+ uuid_str = raw_identifier.sub(/^urn:uuid:/, '')
102
+ UUIDTools::UUID.parse(uuid_str).raw
103
+ end
104
+
105
+ # Parse META-INF/encryption.xml file
106
+ #
107
+ # @return [Array<EncryptionItem>, nil]
108
+ #
109
+ def self.parse_encryption_file(string)
110
+ doc = Nokogiri::XML(string)
111
+ doc.remove_namespaces!
112
+
113
+ encryption_node = doc.at_css('encryption')
114
+ return nil unless encryption_node
115
+
116
+ encryption_node.css('EncryptedData')
117
+ .map do |encrypted_data_node|
118
+ algorithm = encrypted_data_node.at_css('EncryptionMethod')['Algorithm']
119
+ file_path = encrypted_data_node.at_css('CipherData CipherReference')['URI']
120
+
121
+ EncryptionItem.new(algorithm, file_path)
122
+ end
123
+ end
124
+
125
+ # Prepare encryption items with correct keys
126
+ #
127
+ # @param [String] encryption_file
128
+ #
129
+ # @return [Hash<String, EncryptionItem>]
130
+ #
131
+ def _prepare_items(encryption_file)
132
+ idpf_key = EncryptionHandler.parse_idpf_key(@opf.raw_unique_identifier)
133
+ adobe_key = EncryptionHandler.find_and_parse_encryption_key(@opf.identifiers)
134
+
135
+ items = EncryptionHandler.parse_encryption_file(encryption_file)
136
+ items.each do |i|
137
+ if i.algorithm == EncryptionHandler::IDPF_OBFUSCATION
138
+ i.key = idpf_key
139
+ elsif i.algorithm == EncryptionHandler::ADOBE_OBFUSCATION
140
+ i.key = adobe_key
141
+ end
142
+ end
143
+ items.map { |i| [i.file_path, i] }.to_h
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zip'
4
+
5
+ require_relative 'bookspec_generator'
6
+ require_relative 'opf_file'
7
+ require_relative 'nav_file'
8
+ require_relative 'encryption_handler'
9
+
10
+ module Epuber
11
+ class FromFileExecutor
12
+ MIMETYPE_PATH = 'mimetype'
13
+
14
+ ENCRYPTION_PATH = 'META-INF/encryption.xml'
15
+ CONTAINER_PATH = 'META-INF/container.xml'
16
+
17
+ # @param [String] filepath path to EPUB file
18
+ #
19
+ def initialize(filepath)
20
+ @filepath = filepath
21
+ end
22
+
23
+ def run
24
+ UI.puts "📖 Loading EPUB file #{@filepath}"
25
+
26
+ Zip::File.open(@filepath) do |zip_file|
27
+ @zip_file = zip_file
28
+
29
+ validate_mimetype
30
+
31
+ @opf_path = content_opf_path
32
+ UI.puts " Parsing OPF file at #{@opf_path}"
33
+ @opf = OpfFile.new(zip_file.read(@opf_path))
34
+
35
+ if zip_file.find_entry(ENCRYPTION_PATH)
36
+ UI.puts ' Parsing encryption.xml file'
37
+ @encryption_handler = EncryptionHandler.new(zip_file.read(ENCRYPTION_PATH), @opf)
38
+ end
39
+
40
+ UI.puts ' Generating bookspec file'
41
+ basename = File.basename(@filepath, File.extname(@filepath))
42
+ File.write("#{basename}.bookspec", generate_bookspec)
43
+
44
+ export_files
45
+
46
+ UI.puts '' # empty line
47
+ UI.puts <<~TEXT.rstrip.ansi.green
48
+ 🎉 Project initialized.
49
+ Please review generated #{basename}.bookspec file and start using Epuber.
50
+
51
+ For more information about Epuber, please visit https://github.com/epuber-io/epuber/tree/master/docs.
52
+ TEXT
53
+ end
54
+ end
55
+
56
+ # @param [Zip::File] zip_file
57
+ #
58
+ # @return [void]
59
+ #
60
+ def validate_mimetype
61
+ entry = @zip_file.find_entry(MIMETYPE_PATH)
62
+ UI.error! "This is not valid EPUB file (#{MIMETYPE_PATH} file is missing)" if entry.nil?
63
+
64
+ mimetype = @zip_file.read(entry)
65
+
66
+ return if mimetype == 'application/epub+zip'
67
+
68
+ UI.error! <<~MSG
69
+ This is not valid EPUB file (#{MIMETYPE_PATH} file does not contain required application/epub+zip, it is #{mimetype} instead)
70
+ MSG
71
+ end
72
+
73
+ # @param [Zip::File] zip_file
74
+ #
75
+ # @return [String]
76
+ def content_opf_path
77
+ entry = @zip_file.find_entry(CONTAINER_PATH)
78
+ UI.error! "This is not valid EPUB file (#{CONTAINER_PATH} file is missing)" if entry.nil?
79
+
80
+ doc = Nokogiri::XML(@zip_file.read(entry))
81
+ doc.remove_namespaces!
82
+
83
+ rootfile = doc.at_xpath('//rootfile')
84
+ if rootfile.nil?
85
+ UI.error! "This is not valid EPUB file (#{CONTAINER_PATH} file does not contain any <rootfile> element)"
86
+ end
87
+
88
+ rootfile['full-path']
89
+ end
90
+
91
+ # @param [Nokogiri::XML::Document] opf
92
+ # @param [Zip::File] zip_file
93
+ #
94
+ # @return [String]
95
+ def generate_bookspec
96
+ nav_node, nav_mode = @opf.find_nav
97
+ if nav_node
98
+ nav_path = Pathname.new(@opf_path)
99
+ .dirname
100
+ .join(nav_node.href)
101
+ .to_s
102
+ nav = NavFile.new(@zip_file.read(nav_path), nav_mode)
103
+ end
104
+
105
+ BookspecGenerator.new(@opf, nav).generate_bookspec
106
+ end
107
+
108
+ def export_files
109
+ @opf.manifest_items.each_value do |item|
110
+ # ignore text files which are not in spine
111
+ text_file_extensions = %w[.xhtml .html]
112
+ extension = File.extname(item.href).downcase
113
+ if text_file_extensions.include?(extension) &&
114
+ @opf.spine_items.none? { |spine_item| spine_item.idref == item.id }
115
+ UI.puts " Skipping #{item.href} (not in spine)"
116
+ next
117
+ end
118
+
119
+ # ignore ncx file
120
+ if item.media_type == 'application/x-dtbncx+xml'
121
+ UI.puts " Skipping #{item.href} (ncx file)"
122
+ next
123
+ end
124
+
125
+ full_path = Pathname.new(@opf_path)
126
+ .dirname
127
+ .join(item.href)
128
+ .to_s
129
+
130
+ UI.puts " Exporting #{item.href} (from #{full_path})"
131
+
132
+ contents = @zip_file.read(full_path)
133
+ contents = @encryption_handler.process_file(full_path, contents) if @encryption_handler
134
+
135
+ FileUtils.mkdir_p(File.dirname(item.href))
136
+ File.write(item.href, contents)
137
+ end
138
+ end
139
+ end
140
+ end