epuber 0.8.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +9 -0
  3. data/LICENSE.txt +1 -1
  4. data/README.md +2 -2
  5. data/epuber.gemspec +3 -6
  6. data/lib/epuber/book/contributor.rb +10 -6
  7. data/lib/epuber/book/file_request.rb +2 -2
  8. data/lib/epuber/book/target.rb +10 -10
  9. data/lib/epuber/book.rb +2 -2
  10. data/lib/epuber/checker/text_checker.rb +14 -6
  11. data/lib/epuber/checker_transformer_base.rb +1 -1
  12. data/lib/epuber/command/build.rb +6 -1
  13. data/lib/epuber/command/from_file.rb +39 -0
  14. data/lib/epuber/command/init.rb +11 -9
  15. data/lib/epuber/command/server.rb +1 -1
  16. data/lib/epuber/command.rb +1 -0
  17. data/lib/epuber/compiler/file_database.rb +2 -2
  18. data/lib/epuber/compiler/file_finders/abstract.rb +3 -3
  19. data/lib/epuber/compiler/file_resolver.rb +3 -2
  20. data/lib/epuber/compiler/file_types/abstract_file.rb +1 -3
  21. data/lib/epuber/compiler/file_types/bade_file.rb +9 -9
  22. data/lib/epuber/compiler/file_types/css_file.rb +103 -0
  23. data/lib/epuber/compiler/file_types/stylus_file.rb +4 -3
  24. data/lib/epuber/compiler/nav_generator.rb +5 -5
  25. data/lib/epuber/compiler/opf_generator.rb +4 -4
  26. data/lib/epuber/compiler/xhtml_processor.rb +2 -1
  27. data/lib/epuber/compiler.rb +12 -7
  28. data/lib/epuber/config.rb +3 -3
  29. data/lib/epuber/dsl/attribute.rb +1 -1
  30. data/lib/epuber/dsl/attribute_support.rb +4 -4
  31. data/lib/epuber/dsl/object.rb +2 -2
  32. data/lib/epuber/from_file/bookspec_generator.rb +371 -0
  33. data/lib/epuber/from_file/encryption_handler.rb +146 -0
  34. data/lib/epuber/from_file/from_file_executor.rb +140 -0
  35. data/lib/epuber/from_file/nav_file.rb +163 -0
  36. data/lib/epuber/from_file/opf_file.rb +219 -0
  37. data/lib/epuber/plugin.rb +1 -1
  38. data/lib/epuber/server.rb +17 -17
  39. data/lib/epuber/transformer/book_transformer.rb +108 -0
  40. data/lib/epuber/transformer.rb +2 -0
  41. data/lib/epuber/user_interface.rb +2 -2
  42. data/lib/epuber/vendor/ruby_templater.rb +3 -3
  43. data/lib/epuber/vendor/version.rb +3 -3
  44. data/lib/epuber/version.rb +1 -1
  45. metadata +40 -59
@@ -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
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Epuber
4
+ class NavFile
5
+ class NavItem
6
+ # @return [String]
7
+ attr_accessor :href
8
+ # @return [String]
9
+ attr_accessor :title
10
+ # @return [Array<NavItem>]
11
+ attr_accessor :children
12
+
13
+ # @param [String] href
14
+ # @param [String] title
15
+ #
16
+ def initialize(href, title)
17
+ @href = href
18
+ @title = title
19
+ @children = []
20
+ end
21
+
22
+ # @param [String] other_href
23
+ # @param [Boolean] ignore_fragment
24
+ #
25
+ # @return [NavItem, nil]
26
+ #
27
+ def find_by_href(other_href, ignore_fragment: false)
28
+ if ignore_fragment
29
+ other_href = other_href.split('#').first
30
+ self_href = @href.split('#').first
31
+ return self if self_href == other_href
32
+ elsif @href == other_href
33
+ return self
34
+ end
35
+
36
+ @children.find { |item| item.find_by_href(other_href) }
37
+ end
38
+ end
39
+
40
+ class LandmarkItem
41
+ # @return [String]
42
+ #
43
+ attr_accessor :href
44
+
45
+ # @return [String]
46
+ #
47
+ attr_accessor :type
48
+
49
+ # @param [String] href
50
+ # @param [String] type
51
+ #
52
+ def initialize(href, type)
53
+ @href = href
54
+ @type = type
55
+ end
56
+
57
+ # @param [Nokogiri::XML::Node] node
58
+ #
59
+ # @return [LandmarkItem]
60
+ #
61
+ def self.from_node(node)
62
+ new(node['href'], node['type'])
63
+ end
64
+ end
65
+
66
+ LANDMARKS_MAP = {
67
+ 'cover' => :landmark_cover,
68
+ 'bodymatter' => :landmark_start_page,
69
+ 'copyright-page' => :landmark_copyright,
70
+ 'toc' => :landmark_toc,
71
+ }.freeze
72
+
73
+ MODE_NCX = :ncx
74
+ MODE_XHTML = :xhtml
75
+
76
+ # @return [Nokogiri::XML::Document]
77
+ #
78
+ attr_reader :document
79
+
80
+ # @return [:ncx, :xhtml]
81
+ #
82
+ attr_reader :mode
83
+
84
+ # @return [Array<NavItem>]
85
+ #
86
+ attr_reader :items
87
+
88
+ # @return [Array<LandmarkItem>, nil]
89
+ #
90
+ attr_reader :landmarks
91
+
92
+ # @param [string] document
93
+ # @param [:ncx, :xhtml] mode
94
+ #
95
+ def initialize(document, mode)
96
+ raise ArgumentError, 'mode must be :ncx or :xhtml' unless [MODE_NCX, MODE_XHTML].include?(mode)
97
+
98
+ @document = Nokogiri::XML(document)
99
+ @document.remove_namespaces!
100
+
101
+ @mode = mode
102
+ @items = _parse
103
+
104
+ @landmarks = _parse_landmarks
105
+ end
106
+
107
+ # @param [String] href
108
+ # @param [Boolean] ignore_fragment
109
+ #
110
+ # @return [NavItem, nil]
111
+ #
112
+ def find_by_href(href, ignore_fragment: false)
113
+ @items.find { |item| item.find_by_href(href, ignore_fragment: ignore_fragment) }
114
+ end
115
+
116
+ private
117
+
118
+ # @param [Nokogiri::XML::Element] li_node
119
+ #
120
+ # @return [NavItem]
121
+ #
122
+ def _parse_nav_xhtml_item(li_node)
123
+ href = li_node.at_css('a')['href'].strip
124
+ title = li_node.at_css('a').text.strip
125
+
126
+ item = NavItem.new(href, title)
127
+ item.children = li_node.css('ol > li').map { |p| _parse_nav_xhtml_item(p) }
128
+ item
129
+ end
130
+
131
+ # @param [Nokogiri::XML::Element] point_node
132
+ #
133
+ # @return [NavItem]
134
+ #
135
+ def _parse_nav_ncx_item(point_node)
136
+ href = point_node.at_css('content')['src'].strip
137
+ title = point_node.at_css('navLabel text').text.strip
138
+
139
+ item = NavItem.new(href, title)
140
+ item.children = point_node.css('> navPoint').map { |p| _parse_nav_ncx_item(p) }
141
+ item
142
+ end
143
+
144
+ # @return [Array<NavItem>]
145
+ #
146
+ def _parse
147
+ if @mode == MODE_XHTML
148
+ @document.css('nav[type="toc"] > ol > li')
149
+ .map { |point| _parse_nav_xhtml_item(point) }
150
+ elsif @mode == MODE_NCX
151
+ @document.css('navMap > navPoint')
152
+ .map { |point| _parse_nav_ncx_item(point) }
153
+ end
154
+ end
155
+
156
+ def _parse_landmarks
157
+ return nil if @mode != MODE_XHTML
158
+
159
+ @document.css('nav[type="landmarks"] > ol > li > a')
160
+ .map { |node| LandmarkItem.from_node(node) }
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Epuber
4
+ class OpfFile
5
+ class ManifestItem
6
+ # @return [String]
7
+ #
8
+ attr_accessor :id
9
+
10
+ # @return [String]
11
+ #
12
+ attr_accessor :href
13
+
14
+ # @return [String]
15
+ #
16
+ attr_accessor :media_type
17
+
18
+ # @return [String, nil]
19
+ #
20
+ attr_accessor :properties
21
+
22
+ def initialize(id, href, media_type, properties)
23
+ @id = id
24
+ @href = href
25
+ @media_type = media_type
26
+ @properties = properties
27
+ end
28
+
29
+ # @param [ManifestItem] other
30
+ #
31
+ # @return [Boolean]
32
+ #
33
+ def ==(other)
34
+ @id == other.id
35
+ end
36
+
37
+ # @param [Nokogiri::XML::Node] node
38
+ #
39
+ # @return [ManifestItem]
40
+ #
41
+ def self.from_node(node)
42
+ new(node['id'], node['href'], node['media-type'], node['properties'])
43
+ end
44
+ end
45
+
46
+ class SpineItem
47
+ # @return [String]
48
+ #
49
+ attr_accessor :idref
50
+
51
+ # @return [String]
52
+ #
53
+ attr_accessor :linear
54
+
55
+ def initialize(idref, linear)
56
+ @idref = idref
57
+ @linear = linear
58
+ end
59
+
60
+ # @param [SpineItem] other
61
+ #
62
+ # @return [Boolean]
63
+ #
64
+ def ==(other)
65
+ @idref == other.idref
66
+ end
67
+
68
+ # @param [Nokogiri::XML::Node] node
69
+ #
70
+ # @return [SpineItem]
71
+ #
72
+ def self.from_node(node)
73
+ new(node['idref'], node['linear'])
74
+ end
75
+ end
76
+
77
+ class GuideItem
78
+ # @return [String]
79
+ #
80
+ attr_accessor :type
81
+
82
+ # @return [String]
83
+ #
84
+ attr_accessor :href
85
+
86
+ def initialize(type, href)
87
+ @type = type
88
+ @href = href
89
+ end
90
+
91
+ # @param [Nokogiri::XML::Node] node
92
+ #
93
+ # @return [GuideItem]
94
+ #
95
+ def self.from_node(node)
96
+ new(node['type'], node['href'])
97
+ end
98
+ end
99
+
100
+ # reversed map of generator's map
101
+ LANDMARKS_MAP = Compiler::OPFGenerator::LANDMARKS_MAP.map { |k, v| [v, k] }
102
+ .to_h
103
+ .freeze
104
+
105
+ # @return [Nokogiri::XML::Document]
106
+ #
107
+ attr_reader :document
108
+
109
+ # @return [Nokogiri::XML::Node, nil]
110
+ #
111
+ attr_reader :package, :metadata, :manifest, :spine
112
+
113
+ # @return [Hash<String, ManifestItem>]
114
+ #
115
+ attr_reader :manifest_items
116
+
117
+ # @return [Array<SpineItem>]
118
+ #
119
+ attr_reader :spine_items
120
+
121
+ # @return [Array<GuideItem>]
122
+ #
123
+ attr_reader :guide_items
124
+
125
+ # @param [String] document
126
+ def initialize(xml)
127
+ @document = Nokogiri::XML(xml)
128
+ @document.remove_namespaces!
129
+
130
+ @package = @document.at_css('package')
131
+ @metadata = @document.at_css('package metadata')
132
+ @manifest = @document.at_css('package manifest')
133
+ @spine = @document.at_css('package spine')
134
+
135
+ @manifest_items = @document.css('package manifest item')
136
+ .map { |node| ManifestItem.from_node(node) }
137
+ .map { |item| [item.id, item] }
138
+ .to_h
139
+ @spine_items = @document.css('package spine itemref')
140
+ .map { |node| SpineItem.from_node(node) }
141
+ @guide_items = @document.css('package guide reference')
142
+ .map { |node| GuideItem.from_node(node) }
143
+ end
144
+
145
+ # Find nav file in EPUB (both EPUB 2 and EPUB 3). Returns array with nav and type of nav (:xhtml or :ncx).
146
+ #
147
+ # @return [Array<ManifestItem, [:ncx, :xhtml]>, nil] nav, ncx
148
+ #
149
+ def find_nav
150
+ nav = @manifest_items.find { |_, item| item.properties == 'nav' }&.last
151
+ return [nav, NavFile::MODE_XHTML] if nav
152
+
153
+ ncx_id = @spine['toc'] if @spine
154
+ ncx = manifest_file_by_id(ncx_id) if ncx_id
155
+ return [ncx, NavFile::MODE_NCX] if ncx
156
+
157
+ nil
158
+ end
159
+
160
+ # Returns main unique identifier of this EPUB
161
+ #
162
+ # @return [String, nil]
163
+ #
164
+ def raw_unique_identifier
165
+ id = @package['unique-identifier']
166
+ return unless id
167
+
168
+ @metadata.at_css(%(identifier[id="#{id}"]))&.text
169
+ end
170
+
171
+ # Return all identifiers from EPUB metadata
172
+ #
173
+ # @return [Array<Nokogiri::XML::Node>]
174
+ #
175
+ def identifiers
176
+ @metadata.css('identifier')
177
+ end
178
+
179
+ # Find meta refines in EPUB 3 metadata
180
+ #
181
+ # @param [String] id
182
+ # @param [String] property
183
+ #
184
+ # @return [String, nil]
185
+ #
186
+ def find_refines(id, property)
187
+ @metadata.at_css(%(meta[refines="##{id}"][property="#{property}"]))&.text
188
+ end
189
+
190
+ # Find file in <manifest> by id. Throws exception when not found.
191
+ #
192
+ # @param [String] id
193
+ #
194
+ # @return [ManifestItem]
195
+ #
196
+ def manifest_file_by_id(id)
197
+ item = @manifest_items[id]
198
+ raise "Manifest item with id #{id.inspect} not found" unless item
199
+
200
+ item
201
+ end
202
+
203
+ # Find file in <manifest> by href. Throws exception when not found.
204
+ #
205
+ # @param [String] href
206
+ #
207
+ # @return [ManifestItem]
208
+ #
209
+ def manifest_file_by_href(href)
210
+ # remove anchor
211
+ href = href.sub(/#.*$/, '')
212
+
213
+ item = @manifest_items.find { |_, i| i.href == href }&.last
214
+ raise "Manifest item with href #{href.inspect} not found" unless item
215
+
216
+ item
217
+ end
218
+ end
219
+ end
data/lib/epuber/plugin.rb CHANGED
@@ -66,7 +66,7 @@ module Epuber
66
66
  #
67
67
  attr_reader :files
68
68
 
69
- # @param path [String]
69
+ # @param [String] path
70
70
  #
71
71
  def initialize(path)
72
72
  @path = path