persie 0.0.1.alpha

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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +4 -0
  3. data/.rspec +3 -0
  4. data/Gemfile +3 -0
  5. data/LICENSE +21 -0
  6. data/README.md +52 -0
  7. data/Rakefile +85 -0
  8. data/bin/persie +12 -0
  9. data/lib/persie/asciidoctor_ext/htmlbook.rb +1120 -0
  10. data/lib/persie/asciidoctor_ext/sample.rb +54 -0
  11. data/lib/persie/asciidoctor_ext/spine_item_processor.rb +43 -0
  12. data/lib/persie/book.rb +60 -0
  13. data/lib/persie/builder.rb +110 -0
  14. data/lib/persie/builders/epub.rb +434 -0
  15. data/lib/persie/builders/mobi.rb +80 -0
  16. data/lib/persie/builders/pdf.rb +113 -0
  17. data/lib/persie/builders/site.rb +110 -0
  18. data/lib/persie/cli.rb +106 -0
  19. data/lib/persie/dependency.rb +26 -0
  20. data/lib/persie/generator.rb +68 -0
  21. data/lib/persie/server.rb +27 -0
  22. data/lib/persie/ui.rb +27 -0
  23. data/lib/persie/version.rb +3 -0
  24. data/lib/persie.rb +32 -0
  25. data/persie.gemspec +41 -0
  26. data/spec/build_pdf_command_spec.rb +25 -0
  27. data/spec/fixtures/a-book/.gitignore +2 -0
  28. data/spec/fixtures/a-book/Gemfile +3 -0
  29. data/spec/fixtures/a-book/book.adoc +31 -0
  30. data/spec/fixtures/a-book/manuscript/chapter1.adoc +5 -0
  31. data/spec/fixtures/a-book/manuscript/chapter2.adoc +3 -0
  32. data/spec/fixtures/a-book/manuscript/preface.adoc +6 -0
  33. data/spec/fixtures/a-book/themes/pdf/pdf.css +1 -0
  34. data/spec/fixtures/a-book-with-parts/.gitignore +2 -0
  35. data/spec/fixtures/a-book-with-parts/Gemfile +3 -0
  36. data/spec/fixtures/a-book-with-parts/book.adoc +39 -0
  37. data/spec/fixtures/a-book-with-parts/manuscript/chapter1.adoc +4 -0
  38. data/spec/fixtures/a-book-with-parts/manuscript/chapter2.adoc +4 -0
  39. data/spec/fixtures/a-book-with-parts/manuscript/chapter3.adoc +3 -0
  40. data/spec/fixtures/a-book-with-parts/manuscript/chapter4.adoc +3 -0
  41. data/spec/fixtures/a-book-with-parts/manuscript/part1.adoc +3 -0
  42. data/spec/fixtures/a-book-with-parts/manuscript/part2.adoc +3 -0
  43. data/spec/fixtures/a-book-with-parts/manuscript/preface.adoc +4 -0
  44. data/spec/htmlbook_spec.rb +29 -0
  45. data/spec/new_command_spec.rb +57 -0
  46. data/spec/pdf_builder_spec.rb +39 -0
  47. data/spec/spec_helper.rb +14 -0
  48. data/spec/version_command_spec.rb +8 -0
  49. data/templates/Gemfile.txt +3 -0
  50. data/templates/book.adoc.erb +35 -0
  51. data/templates/chapter1.adoc +3 -0
  52. data/templates/chapter2.adoc +3 -0
  53. data/templates/gitignore.txt +2 -0
  54. data/templates/preface.adoc +4 -0
  55. data/workflow.png +0 -0
  56. metadata +278 -0
@@ -0,0 +1,54 @@
1
+ # For sample generation, do some dirty hacks.
2
+
3
+ module Asciidoctor
4
+
5
+ class AbstractBlock
6
+ # Get an array of sample sections.
7
+ def sample_sections
8
+ @blocks.reject! { |b| b.context == :section && !b.sample? }
9
+ @blocks.select { |b| b.context == :section && b.sample? }
10
+ end
11
+ end
12
+
13
+ class Document
14
+ # Get converted sample contents.
15
+ def sample_content
16
+ @attributes.delete('title')
17
+ self.sample_sections.map { |s| s.convert } * "\n"
18
+ end
19
+ end
20
+
21
+ class Section
22
+
23
+ def sample=(bool)
24
+ @sample = bool
25
+ end
26
+
27
+ # Whether this section is in sample.
28
+ def sample?
29
+ if self.attributes.has_key?(:attribute_entries)
30
+ self.attributes[:attribute_entries].each do |entry|
31
+ if entry.name == 'sample' && entry.value != nil
32
+ self.sample = true
33
+ # Not down to top level sections in a part
34
+ downto_subsections(self.sections) unless self.level == 0
35
+ end
36
+ end
37
+ end
38
+
39
+ @sample
40
+ end
41
+
42
+ private
43
+
44
+ def downto_subsections(sections)
45
+ if sections.size > 0
46
+ sections.each do |s|
47
+ s.sample = true
48
+ downto_subsections(s.sections)
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ end
@@ -0,0 +1,43 @@
1
+ require 'asciidoctor/extensions'
2
+
3
+ module Persie
4
+ class SpineItemProcessor < ::Asciidoctor::Extensions::IncludeProcessor
5
+ def initialize(document, sample = false)
6
+ @document = document
7
+ @sample = sample
8
+ end
9
+
10
+ def process(doc, reader, target, attributes)
11
+ include_file = doc.normalize_system_path(target, reader.dir, nil, target_name: 'include file')
12
+ unless ::File.exist? include_file
13
+ warn %(asciidoctor: WARNING: #{reader.line_info}: include file not found: #{include_file})
14
+ return
15
+ end
16
+
17
+ doc.references['spine_items'] ||= []
18
+ basename = File.basename(include_file).split('.')[0..-2].join('.')
19
+
20
+ if @sample
21
+ meta = ::Asciidoctor.load_file include_file,
22
+ safe: doc.safe,
23
+ doctype: :article,
24
+ parse_header_only: true
25
+
26
+ sample_attr = meta.attributes['sample']
27
+ doc.references['spine_items'] << basename unless sample_attr.nil?
28
+ else
29
+ doc.references['spine_items'] << basename
30
+ end
31
+ end
32
+
33
+ def handles? target
34
+ (@document.attr('ebook-format') == 'epub') && (::Asciidoctor::ASCIIDOC_EXTENSIONS.include? ::File.extname(target))
35
+ end
36
+
37
+ def update_config config
38
+ (@config ||= {}).update config
39
+ end
40
+ end
41
+ end
42
+
43
+
@@ -0,0 +1,60 @@
1
+ require 'gepub'
2
+ require 'nokogiri'
3
+ require 'asciidoctor'
4
+
5
+ require_relative 'builders/pdf'
6
+ require_relative 'builders/epub'
7
+ require_relative 'builders/mobi'
8
+ require_relative 'builders/site'
9
+
10
+ module Persie
11
+ class Book
12
+
13
+ # Gets base directory.
14
+ attr_reader :base_dir
15
+
16
+ # Gets builds directory path.
17
+ attr_reader :builds_dir
18
+
19
+ # Gets themes directory path.
20
+ attr_reader :themes_dir
21
+
22
+ # Gets images directory path.
23
+ attr_reader :images_dir
24
+
25
+ # Gets tmp directory path.
26
+ attr_reader :tmp_dir
27
+
28
+ # Gets master file path.
29
+ attr_reader :master_file
30
+
31
+ # Gets/Sets book slug.
32
+ attr_accessor :slug
33
+
34
+ def initialize(dir)
35
+ @base_dir = File.expand_path(dir)
36
+ @tmp_dir = File.join(@base_dir, 'tmp')
37
+ @builds_dir = File.join(@base_dir, 'builds')
38
+ @themes_dir = File.join(@base_dir, 'themes')
39
+ @images_dir = File.join(@base_dir, 'images')
40
+ @master_file = File.join(@base_dir, 'book.adoc')
41
+ end
42
+
43
+ def build_pdf(options = {})
44
+ PDF.new(self, options).build
45
+ end
46
+
47
+ def build_epub(options = {})
48
+ Epub.new(self, options).build
49
+ end
50
+
51
+ def build_mobi(options = {})
52
+ Mobi.new(self, options).build
53
+ end
54
+
55
+ def build_site(options = {})
56
+ Site.new(self, options).build
57
+ end
58
+
59
+ end
60
+ end
@@ -0,0 +1,110 @@
1
+ require 'asciidoctor'
2
+
3
+ require 'fileutils'
4
+
5
+ require_relative 'ui'
6
+ require_relative 'asciidoctor_ext/htmlbook'
7
+ require_relative 'dependency'
8
+ require_relative 'asciidoctor_ext/sample'
9
+
10
+ module Persie
11
+ class Builder
12
+
13
+ END_LINE = '=' * 72
14
+
15
+ # Gets the AsciiDoctor::Document object.
16
+ attr_reader :document
17
+
18
+ def initialize(book, options = {})
19
+ @ui = UI.new(options)
20
+ @book = book
21
+ @options = options
22
+ @document = ::Asciidoctor.load_file(@book.master_file, adoc_options)
23
+ @book.slug = @document.attr('slug', File.basename(@book.base_dir))
24
+ end
25
+
26
+ # Should implement in subclass.
27
+ def build
28
+ raise ::NotImplementedError
29
+ end
30
+
31
+ # If in sample mode, show an indicator in command line.
32
+ def check_sample
33
+ if sample?
34
+ if @document.sample_sections.size == 0
35
+ @ui.error 'Not setting sample, terminated!'
36
+ @ui.info END_LINE
37
+ exit
38
+ end
39
+ @ui.warning "Sample only\n"
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # Generates sample or not.
46
+ def sample?
47
+ return true if @options.has_key? 'sample'
48
+ false
49
+ end
50
+
51
+ # Filts contents, only keep samples if in sample mode.
52
+ def register_spine_item_processor
53
+ require_relative 'asciidoctor_ext/spine_item_processor'
54
+
55
+ sample = sample?
56
+ ::Asciidoctor::Extensions.register do
57
+ include_processor SpineItemProcessor.new(@document, sample)
58
+ end
59
+ end
60
+
61
+ # Options passed into AsciiDoctor loader.
62
+ def adoc_options
63
+ {
64
+ safe: 1,
65
+ backend: 'htmlbook',
66
+ doctype: 'book',
67
+ header_footer: true,
68
+ attributes: adoc_attributes
69
+ }.merge(adoc_custom_options)
70
+ end
71
+
72
+ # Custom Asciidoctor options in subclass.
73
+ def adoc_custom_options
74
+ {}
75
+ end
76
+
77
+ # Attributes as in AsciiDoctor loader option.
78
+ def adoc_attributes
79
+ attrs = {
80
+ 'persie-version' => VERSION,
81
+ 'builds-dir' => @book.builds_dir,
82
+ 'themes-dir' => @book.themes_dir,
83
+ 'imagesdir' => @book.images_dir
84
+ }
85
+
86
+ attrs['is-sample'] = true if sample?
87
+
88
+ attrs.merge(adoc_custom_attributes)
89
+ end
90
+
91
+ # Custom Asciidoctor attributes in subclass.
92
+ def adoc_custom_attributes
93
+ {}
94
+ end
95
+
96
+ # Creates directory if not exists.
97
+ def prepare_directory(path)
98
+ dir = File.dirname(path)
99
+ unless File.exist? dir
100
+ FileUtils.mkdir_p dir
101
+ end
102
+ end
103
+
104
+ # Checks if in test mode.
105
+ def test_mode?
106
+ @options[:test] === true
107
+ end
108
+
109
+ end
110
+ end
@@ -0,0 +1,434 @@
1
+ require 'nokogiri'
2
+
3
+ require 'time'
4
+
5
+ require_relative '../builder'
6
+
7
+ module Persie
8
+ module GepubBuilderMixin
9
+
10
+ FromHtmlSpecialCharsMap = {
11
+ '&lt;' => '<',
12
+ '&gt;' => '>',
13
+ '&amp;' => '&'
14
+ }
15
+ FromHtmlSpecialCharsRx = /(?:#{FromHtmlSpecialCharsMap.keys * '|'})/
16
+ WordJoinerRx = [65279].pack 'U*'
17
+ CsvDelimiterRx = /\s*,\s*/
18
+
19
+ def sanitized_title(title, target = :plain)
20
+ return (@doc.attr 'untitled-label') unless @doc.header?
21
+
22
+ builder = self
23
+
24
+ title = case target
25
+ when :attribute_cdata
26
+ builder.sanitize(title).gsub('"', '&quot;')
27
+ when :element_cdata
28
+ builder.sanitize(title)
29
+ when :pcdata
30
+ title
31
+ when :plain
32
+ builder.sanitize(title).gsub(FromHtmlSpecialCharsRx, FromHtmlSpecialCharsMap)
33
+ end
34
+
35
+ title.gsub WordJoinerRx, ''
36
+ end
37
+
38
+ def sanitize(text)
39
+ if text.include?('<')
40
+ text.gsub(::Asciidoctor::XmlSanitizeRx, '').tr_s(' ', ' ').strip
41
+ else
42
+ text
43
+ end
44
+ end
45
+
46
+ def authors
47
+ if (auts = @doc.attr 'authors')
48
+ auts.split(CsvDelimiterRx)
49
+ else
50
+ []
51
+ end
52
+ end
53
+
54
+ def add_theme_assets
55
+ resources(workdir: @theme_dir) do
56
+ file 'epub.css' if File.exist?('epub.css')
57
+ glob 'fonts/*.*'
58
+ end
59
+ end
60
+
61
+ def add_cover_image
62
+ image = @doc.attr('epub-cover-image', 'cover.png')
63
+ image = File.basename(image) # incase you set this a path
64
+
65
+ if File.exist? image
66
+ resources(workdir: @theme_dir) do
67
+ cover_image image
68
+ end
69
+ end
70
+ end
71
+
72
+ def add_images
73
+ resources(workdir: @images_dir) do
74
+ glob '*.*'
75
+ end
76
+ end
77
+
78
+ def add_content
79
+ builder = self
80
+ spine_items = @spine_items
81
+ spine_item_titles = @spine_item_titles
82
+ resources(workdir: @tmp_dir) do
83
+ nav 'nav.xhtml' if @has_toc
84
+
85
+ ordered do
86
+ spine_items.each_with_index do |item, i|
87
+ file "#{item}.xhtml"
88
+ heading builder.sanitized_title(spine_item_titles[i])
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ end
95
+
96
+ class Epub < Builder
97
+
98
+ # these are not using `include' directive
99
+ SPECIAL_SPINE_ITEMS = ['cover', 'titlepage', 'nav']
100
+
101
+ # Gets/Sets spine items.
102
+ attr_accessor :spine_items
103
+
104
+ # Gets/Sets spine items's titles.
105
+ attr_accessor :spine_item_titles
106
+
107
+ def initialize(book, options = {})
108
+ super
109
+ @tmp_dir = File.join(book.tmp_dir, 'epub')
110
+ @theme_dir = File.join(book.themes_dir, 'epub')
111
+ @build_dir = File.join(book.builds_dir, 'epub')
112
+ @spine_items = []
113
+ @spine_item_titles = []
114
+ end
115
+
116
+ # Builds ePub.
117
+ def build
118
+ @ui.info '=== Build ePub ' << '=' * 57
119
+
120
+ self.check_sample
121
+ self.convert_to_single_xhtml
122
+ self.generate_spine_items
123
+ self.chunk
124
+ self.generate_epub
125
+ self.validate
126
+
127
+ @ui.info END_LINE
128
+ end
129
+
130
+ # Converts to single XHTML file.
131
+ def convert_to_single_xhtml
132
+ @ui.info 'Converting to XHTML...'
133
+ xhtml = @document.convert
134
+ prepare_directory(self.xhtml_path)
135
+ File.write(self.xhtml_path, xhtml)
136
+ @ui.confirm ' XHTMl file created'
137
+ @ui.info " Location: #{self.xhtml_path(true)}\n"
138
+ end
139
+
140
+ # Generates spine items.
141
+ def generate_spine_items
142
+ register_spine_item_processor
143
+
144
+ # Re-loading the master file
145
+ doc = ::Asciidoctor.load_file(@book.master_file, adoc_options)
146
+ @spine_items.concat SPECIAL_SPINE_ITEMS
147
+ @spine_items.concat doc.references['spine_items']
148
+
149
+ @spine_items
150
+ end
151
+
152
+ # Chucks single XHTML file to multiple XHTML files.
153
+ def chunk
154
+ @ui.info 'Chunking files...'
155
+
156
+ content = File.read(self.xhtml_path)
157
+ root = ::Nokogiri::HTML(content)
158
+
159
+ # Adjust spint items
160
+ @has_cover = root.css('div[data-type="cover"]').size > 0
161
+ @has_toc = root.css('nav[data-type="toc"]').size > 0
162
+ self.spine_items.delete('cover') unless @has_cover
163
+ self.spine_items.delete('toc') unless @has_toc
164
+
165
+ correct_nav_href(root)
166
+
167
+ top_level_sections = resolve_top_level_sections(root)
168
+
169
+ # stupid check, incase of something went wrong
170
+ unless top_level_sections.count == self.spine_items.count
171
+ @ui.error ' Count of sections DO NOT equal to spine items count.'
172
+ @ui.error ' Terminated!'
173
+ if @options.debug?
174
+ @ui.info 'sections count: ' + top_level_sections.count
175
+ @ui.info 'spine_items: ' + self.spine_items.inspect
176
+ end
177
+ @ui.info END_LINE
178
+ exit 31
179
+ end
180
+
181
+ sep = '<body data-type="book">'
182
+ tpl_before = content.split(sep).first
183
+ tpl_after = %(</body>\n</html>)
184
+
185
+ top_level_sections.each_with_index do |node, i|
186
+ # Collect the first h1 heading
187
+ title = node.css('h1:first-of-type').first.inner_text
188
+ @spine_item_titles << title
189
+
190
+ # Footnotes
191
+ footnotes_div = generate_footnotes(node)
192
+
193
+ # Write to chunked file
194
+ path = File.join(@tmp_dir, "#{self.spine_items[i]}.xhtml")
195
+ File.open(path, 'w') do |f|
196
+ f.puts tpl_before
197
+ f.puts sep
198
+ f.puts node.to_xhtml
199
+ f.puts footnotes_div
200
+ f.puts tpl_after
201
+ end
202
+ end
203
+
204
+ @ui.confirm ' Done\n'
205
+ end
206
+
207
+ # Generates ePub file.
208
+ def generate_epub
209
+ doc = @document
210
+ tmp_dir = @tmp_dir
211
+ theme_dir = @theme_dir
212
+ images_dir = @book.images_dir
213
+ has_toc = @has_toc
214
+ spine_items = self.spine_items
215
+ spine_item_titles = self.spine_item_titles
216
+
217
+ @ui.info 'Building ePub...'
218
+
219
+ builder = ::GEPUB::Builder.new do
220
+ extend GepubBuilderMixin
221
+
222
+ @doc = doc
223
+ @tmp_dir = tmp_dir
224
+ @theme_dir = theme_dir
225
+ @images_dir = images_dir
226
+ @has_toc = has_toc
227
+ @spine_items = spine_items
228
+ @spine_item_titles = spine_item_titles
229
+
230
+ language doc.attr('lang', 'en')
231
+ id 'pub-language'
232
+
233
+ scheme = doc.attr('epub-identifier-scheme', 'uuid').downcase
234
+ scheme = 'uuid' unless ['uuid', 'isbn'].include? scheme
235
+ unique_identifier doc.attr(scheme), 'pub-identifier', scheme
236
+
237
+ title sanitized_title(doc.doctitle)
238
+ id 'pub-title'
239
+
240
+ if doc.attr? 'publisher'
241
+ publisher(publisher_name = doc.attr('publisher'))
242
+ # marc role: Book producer (see http://www.loc.gov/marc/relators/relaterm.html)
243
+ creator doc.attr('producer', publisher_name), 'bkp'
244
+ else
245
+ # Use producer as both publisher and producer if publisher isn't specified
246
+ if doc.attr? 'producer'
247
+ producer_name = doc.attr('producer')
248
+ publisher producer_name
249
+ # marc role: Book producer (see http://www.loc.gov/marc/relators/relaterm.html)
250
+ creator producer_name, 'bkp'
251
+ # Use author as creator if both publisher or producer are absent
252
+ elsif doc.attr? 'author'
253
+ # marc role: Author (see http://www.loc.gov/marc/relators/relaterm.html)
254
+ creator doc.attr('author'), 'aut'
255
+ end
256
+ end
257
+
258
+ if doc.attr? 'creator'
259
+ # marc role: Creator (see http://www.loc.gov/marc/relators/relaterm.html)
260
+ creator doc.attr('creator'), 'cre'
261
+ else
262
+ # marc role: Manufacturer (see http://www.loc.gov/marc/relators/relaterm.html)
263
+ creator 'persie', 'mfr'
264
+ end
265
+
266
+ contributors(*authors) unless authors.empty?
267
+
268
+ if doc.attr? 'revdate'
269
+ real_date = Time.parse(doc.attr 'revdate').iso8601
270
+ date real_date
271
+ else
272
+ date Time.now.strftime('%Y-%m-%dT%H:%M:%SZ')
273
+ end
274
+
275
+ if doc.attr? 'description'
276
+ description(doc.attr 'description')
277
+ end
278
+
279
+ if doc.attr? 'copyright'
280
+ rights(doc.attr 'copyright')
281
+ end
282
+
283
+ add_theme_assets
284
+ add_cover_image
285
+ add_images
286
+ add_content
287
+ end
288
+
289
+ prepare_directory(self.epub_path)
290
+ builder.generate_epub(self.epub_path)
291
+ @ui.confirm ' ePub file created'
292
+ @ui.info " Location: #{self.epub_path(true)}"
293
+ end
294
+
295
+ # Validates ePub file, optionally.
296
+ def validate
297
+ if @options.validate?
298
+ @ui.info "\nValidating..."
299
+ if Dependency.epubcheck_installed?
300
+ system "epubcheck #{epub_path}"
301
+ if $?.to_i == 0
302
+ @ui.confirm ' PASS'
303
+ else
304
+ @ui.error ' ERROR'
305
+ end
306
+ else
307
+ @ui.warning ' epubcheck not installed, skip validation'
308
+ end
309
+ end
310
+ end
311
+
312
+ # Gets XHTML file path.
313
+ def xhtml_path(relative = false)
314
+ name = sample? ? "#{@book.slug}-sample" : @book.slug
315
+ path = File.join('tmp', 'epub', "#{name}.html")
316
+ return path if relative
317
+
318
+ File.join(@book.base_dir, path)
319
+ end
320
+
321
+ # Gets ePub file path.
322
+ def epub_path(relative = false)
323
+ name = sample? ? "#{@book.slug}-sample" : @book.slug
324
+ path = File.join('builds', 'epub', "#{name}.epub")
325
+ return path if relative
326
+
327
+ File.join(@book.base_dir, path)
328
+ end
329
+
330
+ private
331
+
332
+ def adoc_custom_attributes
333
+ {
334
+ 'imagesdir' => 'images',
335
+ 'ebook-format' => 'epub',
336
+ 'outfilesuffix' => '.xhtml'
337
+ }
338
+ end
339
+
340
+ # Corrects navigation items' href.
341
+ #
342
+ # Example:
343
+ # href="#id" => href="path.xhtml#id"
344
+ def correct_nav_href(node)
345
+ return unless (ols = node.css('nav[data-type="toc"]> ol')).size > 0
346
+
347
+ spine_items_dup = self.spine_items.dup
348
+ SPECIAL_SPINE_ITEMS.each { |i| spine_items_dup.delete(i) }
349
+
350
+ top_level_lis = ols.first.css('> li')
351
+ j = 0
352
+ top_level_lis.each do |li|
353
+ if li['data-type'] == 'part'
354
+ first_a = li.css('> a').first
355
+ first_a_href = first_a['href']
356
+ first_a['href'] = "#{spine_items_dup[j]}.xhtml#{first_a_href}"
357
+ if (li_ols = li.css('> ol')).size > 0
358
+ li_ol = li_ols.first
359
+ li_ol.css('> li').each do |lli|
360
+ j += 1
361
+ lli.css('a').each do |a|
362
+ old_href = a['href']
363
+ a['href'] = "#{spine_items_dup[j]}.xhtml#{old_href}"
364
+ end
365
+ end
366
+ j += 1
367
+ end
368
+ else
369
+ li.css('a').each do |a|
370
+ old_href = a['href']
371
+ a['href'] = "#{spine_items_dup[j]}.xhtml#{old_href}"
372
+ end
373
+ j += 1
374
+ end
375
+ end
376
+ end
377
+
378
+
379
+ # Resolves top level sections.
380
+ #
381
+ # When there are parts, takes sections within each part out.
382
+ def resolve_top_level_sections(node)
383
+ if (parts = node.css('body > div[data-type="part"]')).size > 0
384
+ parts.each do |part|
385
+ sections = part.css('> section')
386
+ sections.each do |sect|
387
+ part.delete sect
388
+ end
389
+ part.add_next_sibling(sections)
390
+ end
391
+ end
392
+
393
+ node.css('body > *')
394
+
395
+ end
396
+
397
+ # Generates footnotes for one node.
398
+ def generate_footnotes(node)
399
+ footnotes_div = nil
400
+ footnotes = node.css('span[data-type="footnote"]')
401
+ if footnotes.length > 0
402
+ footnotes_div = generate_footnotes_div(footnotes)
403
+ replace_footnote_with_sup(footnotes)
404
+ end
405
+
406
+ footnotes_div
407
+ end
408
+
409
+ # Generate a footnotes div element.
410
+ def generate_footnotes_div(footnotes)
411
+ result = ['<div class="footnotes">']
412
+ result << '<ol>'
413
+ footnotes.each_with_index do |fn, i|
414
+ index = i + 1
415
+ result << %(<li id="fn-#{index}" epub:type="footnote">#{fn.inner_text}</li>)
416
+ end
417
+ result << '</ol>'
418
+ result << '</div>'
419
+
420
+ result * "\n"
421
+ end
422
+
423
+ def replace_footnote_with_sup(footnotes)
424
+ footnotes.each_with_index do |fn, i|
425
+ index = i + 1
426
+ fn.replace(%(<sup><a href="#fn-#{index}">#{index}</a></sup>))
427
+ end
428
+
429
+ nil
430
+ end
431
+
432
+ end
433
+
434
+ end