epuber 0.7.4 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
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