asciidoctor-epub3 1.5.0 → 2.0.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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'mime/types'
4
4
  require 'open3'
5
+ require 'sass'
5
6
  require_relative 'font_icon_map'
6
7
 
7
8
  module Asciidoctor
@@ -15,18 +16,19 @@ module Asciidoctor
15
16
 
16
17
  register_for 'epub3'
17
18
 
18
- def write output, target
19
+ def write(output, target)
19
20
  epub_file = @format == :kf8 ? %(#{::Asciidoctor::Helpers.rootname target}-kf8.epub) : target
20
21
  output.generate_epub epub_file
21
22
  logger.debug %(Wrote #{@format.upcase} to #{epub_file})
22
23
  if @extract
23
- extract_dir = epub_file.sub EpubExtensionRx, ''
24
+ extract_dir = epub_file.sub EPUB_EXTENSION_RX, ''
24
25
  ::FileUtils.remove_dir extract_dir if ::File.directory? extract_dir
25
26
  ::Dir.mkdir extract_dir
26
27
  ::Dir.chdir extract_dir do
27
28
  ::Zip::File.open epub_file do |entries|
28
29
  entries.each do |entry|
29
30
  next unless entry.file?
31
+
30
32
  unless (entry_dir = ::File.dirname entry.name) == '.' || (::File.directory? entry_dir)
31
33
  ::FileUtils.mkdir_p entry_dir
32
34
  end
@@ -38,69 +40,80 @@ module Asciidoctor
38
40
  end
39
41
 
40
42
  if @format == :kf8
41
- # QUESTION shouldn't we validate this epub file too?
43
+ # QUESTION: shouldn't we validate this epub file too?
42
44
  distill_epub_to_mobi epub_file, target, @compress
43
45
  elsif @validate
44
46
  validate_epub epub_file
45
47
  end
46
48
  end
47
49
 
48
- CsvDelimiterRx = /\s*,\s*/
50
+ CSV_DELIMITED_RX = /\s*,\s*/.freeze
49
51
 
50
52
  DATA_DIR = ::File.expand_path ::File.join(__dir__, '..', '..', 'data')
51
- ImageMacroRx = /^image::?(.*?)\[(.*?)\]$/
52
- ImgSrcScanRx = /<img src="(.+?)"/
53
- SvgImgSniffRx = /<img src=".+?\.svg"/
53
+ IMAGE_MACRO_RX = /^image::?(.*?)\[(.*?)\]$/.freeze
54
+ IMAGE_SRC_SCAN_RX = /<img src="(.+?)"/.freeze
55
+ SVG_IMG_SNIFF_RX = /<img src=".+?\.svg"/.freeze
54
56
 
55
- LF = ?\n
56
- NoBreakSpace = '&#xa0;'
57
- RightAngleQuote = '&#x203a;'
58
- CalloutStartNum = %(\u2460)
57
+ LF = "\n"
58
+ NO_BREAK_SPACE = '&#xa0;'
59
+ RIGHT_ANGLE_QUOTE = '&#x203a;'
60
+ CALLOUT_START_NUM = %(\u2460)
59
61
 
60
- CharEntityRx = /&#(\d{2,6});/
61
- XmlElementRx = /<\/?.+?>/
62
- TrailingPunctRx = /[[:punct:]]$/
62
+ CHAR_ENTITY_RX = /&#(\d{2,6});/.freeze
63
+ XML_ELEMENT_RX = %r{</?.+?>}.freeze
64
+ TRAILING_PUNCT_RX = /[[:punct:]]$/.freeze
63
65
 
64
- FromHtmlSpecialCharsMap = {
66
+ FROM_HTML_SPECIAL_CHARS_MAP = {
65
67
  '&lt;' => '<',
66
68
  '&gt;' => '>',
67
- '&amp;' => '&',
68
- }
69
+ '&amp;' => '&'
70
+ }.freeze
69
71
 
70
- FromHtmlSpecialCharsRx = /(?:#{FromHtmlSpecialCharsMap.keys * '|'})/
72
+ FROM_HTML_SPECIAL_CHARS_RX = /(?:#{FROM_HTML_SPECIAL_CHARS_MAP.keys * '|'})/.freeze
71
73
 
72
- ToHtmlSpecialCharsMap = {
74
+ TO_HTML_SPECIAL_CHARS_MAP = {
73
75
  '&' => '&amp;',
74
76
  '<' => '&lt;',
75
- '>' => '&gt;',
76
- }
77
-
78
- ToHtmlSpecialCharsRx = /[#{ToHtmlSpecialCharsMap.keys.join}]/
79
-
80
- EpubExtensionRx = /\.epub$/i
81
- KindlegenCompression = ::Hash['0', '-c0', '1', '-c1', '2', '-c2', 'none', '-c0', 'standard', '-c1', 'huffdic', '-c2']
82
-
83
- (QUOTE_TAGS = {
84
- monospaced: ['<code>', '</code>', true],
85
- emphasis: ['<em>', '</em>', true],
86
- strong: ['<strong>', '</strong>', true],
87
- double: ['', ''],
88
- single: ['‘', '’'],
89
- mark: ['<mark>', '</mark>', true],
90
- superscript: ['<sup>', '</sup>', true],
91
- subscript: ['<sub>', '</sub>', true],
92
- asciimath: ['<code>', '</code>', true],
93
- latexmath: ['<code>', '</code>', true],
94
- }).default = ['', '']
95
-
96
- def initialize backend, opts = {}
77
+ '>' => '&gt;'
78
+ }.freeze
79
+
80
+ TO_HTML_SPECIAL_CHARS_RX = /[#{TO_HTML_SPECIAL_CHARS_MAP.keys.join}]/.freeze
81
+
82
+ EPUB_EXTENSION_RX = /\.epub$/i.freeze
83
+ KINDLEGEN_COMPRESSION = {
84
+ '0' => '-c0',
85
+ '1' => '-c1',
86
+ '2' => '-c2',
87
+ 'none' => '-c0',
88
+ 'standard' => '-c1',
89
+ 'huffdic' => '-c2'
90
+ }.freeze
91
+
92
+ QUOTE_TAGS = begin
93
+ tags = {
94
+ monospaced: ['<code>', '</code>', true],
95
+ emphasis: ['<em>', '</em>', true],
96
+ strong: ['<strong>', '</strong>', true],
97
+ double: ['“', '”'],
98
+ single: ['‘', '’'],
99
+ mark: ['<mark>', '</mark>', true],
100
+ superscript: ['<sup>', '</sup>', true],
101
+ subscript: ['<sub>', '</sub>', true],
102
+ asciimath: ['<code>', '</code>', true],
103
+ latexmath: ['<code>', '</code>', true]
104
+ }
105
+ tags.default = ['', '']
106
+ tags.freeze
107
+ end
108
+
109
+ def initialize(backend, opts = {})
97
110
  super
98
111
  basebackend 'html'
99
112
  outfilesuffix '.epub' # dummy outfilesuffix since it may be .mobi
100
113
  htmlsyntax 'xml'
101
114
  end
102
115
 
103
- def convert node, name = nil, _opts = {}
116
+ def convert(node, name = nil, _opts = {})
104
117
  method_name = %(convert_#{name ||= node.node_name})
105
118
  if respond_to? method_name
106
119
  send method_name, node
@@ -110,44 +123,41 @@ module Asciidoctor
110
123
  end
111
124
  end
112
125
 
113
- # See https://asciidoctor.org/docs/user-manual/#book-parts-and-chapters
114
- def get_chapter_name node
115
- if node.document.doctype != 'book'
116
- return Asciidoctor::Document === node ? node.attr('docname') || node.id : nil
117
- end
118
- return (node.id || 'preamble') if node.context == :preamble && node.level == 0
119
- chapter_level = [node.document.attr('epub-chapter-level', 1).to_i, 1].max
120
- Asciidoctor::Section === node && node.level <= chapter_level ? node.id : nil
126
+ # @param node [Asciidoctor::AbstractNode]
127
+ # @return [String, nil]
128
+ def get_chapter_filename(node)
129
+ node.id if node.chapter?
121
130
  end
122
131
 
123
- def get_numbered_title node
132
+ def get_numbered_title(node)
124
133
  doc_attrs = node.document.attributes
125
134
  level = node.level
126
135
  if node.caption
127
- title = node.captioned_title
136
+ node.captioned_title
128
137
  elsif node.respond_to?(:numbered) && node.numbered && level <= (doc_attrs['sectnumlevels'] || 3).to_i
129
138
  if level < 2 && node.document.doctype == 'book'
130
- if node.sectname == 'chapter'
131
- title = %(#{(signifier = doc_attrs['chapter-signifier']) ? "#{signifier} " : ''}#{node.sectnum} #{node.title})
132
- elsif node.sectname == 'part'
133
- title = %(#{(signifier = doc_attrs['part-signifier']) ? "#{signifier} " : ''}#{node.sectnum nil, ':'} #{node.title})
139
+ case node.sectname
140
+ when 'chapter'
141
+ %(#{(signifier = doc_attrs['chapter-signifier']) ? "#{signifier} " : ''}#{node.sectnum} #{node.title})
142
+ when 'part'
143
+ %(#{(signifier = doc_attrs['part-signifier']) ? "#{signifier} " : ''}#{node.sectnum nil,
144
+ ':'} #{node.title})
134
145
  else
135
- title = %(#{node.sectnum} #{node.title})
146
+ %(#{node.sectnum} #{node.title})
136
147
  end
137
148
  else
138
- title = %(#{node.sectnum} #{node.title})
149
+ %(#{node.sectnum} #{node.title})
139
150
  end
140
151
  else
141
- title = node.title
152
+ node.title
142
153
  end
143
- title
144
154
  end
145
155
 
146
156
  def icon_names
147
157
  @icon_names ||= []
148
158
  end
149
159
 
150
- def convert_document node
160
+ def convert_document(node)
151
161
  @format = node.attr('ebook-format').to_sym
152
162
 
153
163
  @validate = node.attr? 'ebook-validate'
@@ -169,9 +179,9 @@ module Asciidoctor
169
179
  @book.primary_identifier node.id, 'pub-identifier', 'uuid'
170
180
  end
171
181
  # replace with next line once the attributes argument is supported
172
- #unique_identifier doc.id, 'pub-id', 'uuid', 'scheme' => 'xsd:string'
182
+ # unique_identifier doc.id, 'pub-id', 'uuid', 'scheme' => 'xsd:string'
173
183
 
174
- # NOTE we must use :plain_text here since gepub reencodes
184
+ # NOTE: we must use :plain_text here since gepub reencodes
175
185
  @book.add_title sanitize_doctitle_xml(node, :plain_text), id: 'pub-title'
176
186
 
177
187
  # see https://www.w3.org/publishing/epub3/epub-packages.html#sec-opf-dccreator
@@ -181,7 +191,7 @@ module Asciidoctor
181
191
  end
182
192
 
183
193
  publisher = node.attr 'publisher'
184
- # NOTE Use producer as both publisher and producer if publisher isn't specified
194
+ # NOTE: Use producer as both publisher and producer if publisher isn't specified
185
195
  publisher = node.attr 'producer' if publisher.nil_or_empty?
186
196
  @book.publisher = publisher unless publisher.nil_or_empty?
187
197
 
@@ -207,7 +217,7 @@ module Asciidoctor
207
217
  @book.source = node.attr 'source' if node.attr? 'source'
208
218
  @book.rights = node.attr 'copyright' if node.attr? 'copyright'
209
219
 
210
- (node.attr 'keywords', '').split(CsvDelimiterRx).each do |s|
220
+ (node.attr 'keywords', '').split(CSV_DELIMITED_RX).each do |s|
211
221
  @book.metadata.add_metadata 'subject', s
212
222
  end
213
223
 
@@ -216,7 +226,8 @@ module Asciidoctor
216
226
  series_volume = node.attr 'series-volume', 1
217
227
  series_id = node.attr 'series-id'
218
228
 
219
- series_meta = @book.metadata.add_metadata 'meta', series_name, id: 'pub-collection', group_position: series_volume
229
+ series_meta = @book.metadata.add_metadata 'meta', series_name, id: 'pub-collection',
230
+ group_position: series_volume
220
231
  series_meta['property'] = 'belongs-to-collection'
221
232
  series_meta.refine 'dcterms:identifier', series_id unless series_id.nil?
222
233
  # Calibre only understands 'series'
@@ -228,10 +239,17 @@ module Asciidoctor
228
239
  landmarks = []
229
240
 
230
241
  front_cover = add_cover_page node, 'front-cover'
242
+ if front_cover.nil? && @format != :kf8 && node.doctype == 'book'
243
+ # TODO(#352): add textual front cover similar to PDF
244
+ end
245
+
231
246
  landmarks << { type: 'cover', href: front_cover.href, title: 'Front Cover' } unless front_cover.nil?
232
247
 
233
248
  front_matter_page = add_front_matter_page node
234
- landmarks << { type: 'frontmatter', href: front_matter_page.href, title: 'Front Matter' } unless front_matter_page.nil?
249
+ unless front_matter_page.nil?
250
+ landmarks << { type: 'frontmatter', href: front_matter_page.href,
251
+ title: 'Front Matter' }
252
+ end
235
253
 
236
254
  nav_item = @book.add_item('nav.xhtml', id: 'nav').nav
237
255
 
@@ -256,17 +274,26 @@ module Asciidoctor
256
274
  _back_cover = add_cover_page node, 'back-cover'
257
275
  # TODO: add landmark for back cover? But what epub:type?
258
276
 
259
- landmarks << { type: 'bodymatter', href: %(#{get_chapter_name toc_items[0]}.xhtml), title: 'Start of Content' } unless toc_items.empty?
277
+ unless toc_items.empty?
278
+ landmarks << { type: 'bodymatter', href: %(#{get_chapter_filename toc_items[0]}.xhtml),
279
+ title: 'Start of Content' }
280
+ end
260
281
 
261
282
  toc_items.each do |item|
262
- landmarks << { type: item.style, href: %(#{get_chapter_name item}.xhtml), title: item.title } if %w(appendix bibliography glossary index preface).include? item.style
283
+ next unless %w[appendix bibliography glossary index preface].include? item.style
284
+
285
+ landmarks << {
286
+ type: item.style,
287
+ href: %(#{get_chapter_filename item}.xhtml),
288
+ title: item.title
289
+ }
263
290
  end
264
291
 
265
292
  nav_item.add_content postprocess_xhtml(nav_doc(node, toc_items, landmarks, outlinelevels))
266
293
  # User is not supposed to see landmarks, so pass empty array here
267
294
  toc_item&.add_content postprocess_xhtml(nav_doc(node, toc_items, [], toclevels))
268
295
 
269
- # NOTE gepub doesn't support building a ncx TOC with depth > 1, so do it ourselves
296
+ # NOTE: gepub doesn't support building a ncx TOC with depth > 1, so do it ourselves
270
297
  toc_ncx = ncx_doc node, toc_items, outlinelevels
271
298
  @book.add_item 'toc.ncx', content: toc_ncx.to_ios, id: 'ncx'
272
299
 
@@ -278,7 +305,7 @@ module Asciidoctor
278
305
  logger.warn %(path is reserved for cover artwork: #{name}; skipping file found in content)
279
306
  elsif file[:path].nil? || File.readable?(file[:path])
280
307
  mime_types = MIME::Types.type_for name
281
- mime_types.delete_if {|x| x.media_type != file[:media_type] }
308
+ mime_types.delete_if { |x| x.media_type != file[:media_type] }
282
309
  preferred_mime_type = mime_types.empty? ? nil : mime_types[0].content_type
283
310
  @book.add_item name, content: file[:path], media_type: preferred_mime_type
284
311
  else
@@ -286,11 +313,11 @@ module Asciidoctor
286
313
  end
287
314
  end
288
315
 
289
- #add_metadata 'ibooks:specified-fonts', true
316
+ # add_metadata 'ibooks:specified-fonts', true
290
317
 
291
318
  add_theme_assets node
292
319
  if node.doctype != 'book'
293
- usernames = [node].map {|item| item.attr 'username' }.compact.uniq
320
+ usernames = [node].map { |item| item.attr 'username' }.compact.uniq
294
321
  add_profile_images node, usernames
295
322
  end
296
323
 
@@ -298,17 +325,16 @@ module Asciidoctor
298
325
  end
299
326
 
300
327
  # FIXME: move to Asciidoctor::Helpers
301
- def sanitize_doctitle_xml doc, content_spec
328
+ def sanitize_doctitle_xml(doc, content_spec)
302
329
  doctitle = doc.doctitle use_fallback: true
303
330
  sanitize_xml doctitle, content_spec
304
331
  end
305
332
 
306
333
  # FIXME: move to Asciidoctor::Helpers
307
- def sanitize_xml content, content_spec
308
- if content_spec != :pcdata && (content.include? '<')
309
- if (content = (content.gsub XmlElementRx, '').strip).include? ' '
310
- content = content.tr_s ' ', ' '
311
- end
334
+ def sanitize_xml(content, content_spec)
335
+ if content_spec != :pcdata && (content.include? '<') && ((content = (content.gsub XML_ELEMENT_RX,
336
+ '').strip).include? ' ')
337
+ content = content.tr_s ' ', ' '
312
338
  end
313
339
 
314
340
  case content_spec
@@ -318,8 +344,8 @@ module Asciidoctor
318
344
  # noop
319
345
  when :plain_text
320
346
  if content.include? ';'
321
- content = content.gsub(CharEntityRx) { [$1.to_i].pack 'U*' } if content.include? '&#'
322
- content = content.gsub FromHtmlSpecialCharsRx, FromHtmlSpecialCharsMap
347
+ content = content.gsub(CHAR_ENTITY_RX) { [::Regexp.last_match(1).to_i].pack 'U*' } if content.include? '&#'
348
+ content = content.gsub FROM_HTML_SPECIAL_CHARS_RX, FROM_HTML_SPECIAL_CHARS_MAP
323
349
  end
324
350
  else
325
351
  raise ::ArgumentError, %(Unknown content spec: #{content_spec})
@@ -327,11 +353,12 @@ module Asciidoctor
327
353
  content
328
354
  end
329
355
 
330
- def add_chapter node
331
- docid = get_chapter_name node
332
- return nil if docid.nil?
356
+ # @param node [Asciidoctor::AbstractBlock]
357
+ def add_chapter(node)
358
+ filename = get_chapter_filename node
359
+ return nil if filename.nil?
333
360
 
334
- chapter_item = @book.add_ordered_item %(#{docid}.xhtml)
361
+ chapter_item = @book.add_ordered_item %(#{filename}.xhtml)
335
362
 
336
363
  doctitle = node.document.doctitle partition: true, use_fallback: true
337
364
  chapter_title = doctitle.combined
@@ -364,12 +391,12 @@ module Asciidoctor
364
391
  @xrefs_seen.clear
365
392
  content = node.content
366
393
 
367
- # NOTE must run after content is resolved
394
+ # NOTE: must run after content is resolved
368
395
  # TODO perhaps create dynamic CSS file?
369
396
  if icon_names.empty?
370
397
  icon_css_head = ''
371
398
  else
372
- icon_defs = icon_names.map {|name|
399
+ icon_defs = icon_names.map { |name|
373
400
  %(.i-#{name}::before { content: "#{FontIconMap.unicode name}"; })
374
401
  } * LF
375
402
  icon_css_head = %(<style>
@@ -378,21 +405,26 @@ module Asciidoctor
378
405
  )
379
406
  end
380
407
 
381
- header = (title || subtitle) ? %(<header>
408
+ header = if title || subtitle
409
+ %(<header>
382
410
  <div class="chapter-header">
383
411
  #{byline}<h1 class="chapter-title">#{title}#{subtitle ? %(<small class="subtitle">#{subtitle}</small>) : ''}</h1>
384
412
  </div>
385
- </header>) : ''
413
+ </header>)
414
+ else
415
+ ''
416
+ end
386
417
 
387
418
  # We want highlighter CSS to be stored in a separate file
388
419
  # in order to avoid style duplication across chapter files
389
420
  linkcss = true
390
421
 
391
- # NOTE kindlegen seems to mangle the <header> element, so we wrap its content in a div
392
- lines = [%(<!DOCTYPE html>
393
- <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xmlns:mml="http://www.w3.org/1998/Math/MathML" xml:lang="#{lang = node.document.attr 'lang', 'en'}" lang="#{lang}">
422
+ # NOTE: kindlegen seems to mangle the <header> element, so we wrap its content in a div
423
+ lines = [%(<?xml version='1.0' encoding='utf-8'?>
424
+ <!DOCTYPE html>
425
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xmlns:mml="http://www.w3.org/1998/Math/MathML" xml:lang="#{lang = node.document.attr 'lang',
426
+ 'en'}" lang="#{lang}">
394
427
  <head>
395
- <meta charset="UTF-8"/>
396
428
  <title>#{chapter_title}</title>
397
429
  <link rel="stylesheet" type="text/css" href="styles/epub3.css"/>
398
430
  <link rel="stylesheet" type="text/css" href="styles/epub3-css3-only.css" media="(min-device-width: 0px)"/>
@@ -409,24 +441,27 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
409
441
  syntax_hl = node.document.syntax_highlighter
410
442
  epub_type_attr = node.respond_to?(:section) && node.sectname != 'section' ? %( epub:type="#{node.sectname}") : ''
411
443
 
412
- lines << (syntax_hl.docinfo :head, node, linkcss: linkcss, self_closing_tag_slash: '/') if syntax_hl&.docinfo? :head
444
+ if syntax_hl&.docinfo? :head
445
+ lines << (syntax_hl.docinfo :head, node, linkcss: linkcss,
446
+ self_closing_tag_slash: '/')
447
+ end
413
448
 
414
449
  lines << %(</head>
415
450
  <body>
416
- <section class="chapter" title=#{chapter_title.encode xml: :attr}#{epub_type_attr} id="#{docid}">
451
+ <section class="chapter" title=#{chapter_title.encode xml: :attr}#{epub_type_attr} id="#{filename}">
417
452
  #{header}
418
453
  #{content})
419
454
 
420
455
  unless (fns = node.document.footnotes - @footnotes).empty?
421
456
  @footnotes += fns
422
457
 
423
- # NOTE kindlegen seems to mangle the <footer> element, so we wrap its content in a div
458
+ # NOTE: kindlegen seems to mangle the <footer> element, so we wrap its content in a div
424
459
  lines << '<footer>
425
460
  <div class="chapter-footer">
426
461
  <div class="footnotes">'
427
462
  fns.each do |footnote|
428
463
  lines << %(<aside id="note-#{footnote.index}" epub:type="footnote">
429
- <p><sup class="noteref"><a href="#noteref-#{footnote.index}">#{footnote.index}</a></sup> #{footnote.text}</p>
464
+ <p>#{footnote.text}</p>
430
465
  </aside>)
431
466
  end
432
467
  lines << '</div>
@@ -436,7 +471,10 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
436
471
 
437
472
  lines << '</section>'
438
473
 
439
- lines << (syntax_hl.docinfo :footer, node.document, linkcss: linkcss, self_closing_tag_slash: '/') if syntax_hl&.docinfo? :footer
474
+ if syntax_hl&.docinfo? :footer
475
+ lines << (syntax_hl.docinfo :footer, node.document, linkcss: linkcss,
476
+ self_closing_tag_slash: '/')
477
+ end
440
478
 
441
479
  lines << '</body>
442
480
  </html>'
@@ -451,39 +489,43 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
451
489
  chapter_item
452
490
  end
453
491
 
454
- def convert_section node
455
- if add_chapter(node).nil?
456
- hlevel = node.level
457
- epub_type_attr = node.sectname != 'section' ? %( epub:type="#{node.sectname}") : ''
458
- div_classes = [%(sect#{node.level}), node.role].compact
459
- title = get_numbered_title node
460
- %(<section class="#{div_classes * ' '}" title=#{title.encode xml: :attr}#{epub_type_attr}>
461
- <h#{hlevel} id="#{node.id}">#{title}</h#{hlevel}>#{(content = node.content).empty? ? '' : %(
462
- #{content})}
492
+ # @param node [Asciidoctor::Section]
493
+ def convert_section(node)
494
+ return unless add_chapter(node).nil?
495
+
496
+ hlevel = node.level.clamp 1, 6
497
+ epub_type_attr = node.sectname == 'section' ? '' : %( epub:type="#{node.sectname}")
498
+ div_classes = [%(sect#{node.level}), node.role].compact
499
+ title = get_numbered_title node
500
+ %(<section class="#{div_classes * ' '}" title=#{title.encode xml: :attr}#{epub_type_attr}>
501
+ <h#{hlevel} id="#{node.id}">#{title}</h#{hlevel}>#{if (content = node.content).empty?
502
+ ''
503
+ else
504
+ %(
505
+ #{content})
506
+ end}
463
507
  </section>)
464
- end
465
508
  end
466
509
 
467
- # NOTE embedded is used for AsciiDoc table cell content
468
- def convert_embedded node
510
+ # NOTE: embedded is used for AsciiDoc table cell content
511
+ def convert_embedded(node)
469
512
  node.content
470
513
  end
471
514
 
472
515
  # TODO: support use of quote block as abstract
473
- def convert_preamble node
474
- if add_chapter(node).nil?
475
- if (first_block = node.blocks[0]) && first_block.style == 'abstract'
476
- convert_abstract first_block
477
- # REVIEW: should we treat the preamble as an abstract in general?
478
- elsif first_block && node.blocks.size == 1
479
- convert_abstract first_block
480
- else
481
- node.content
482
- end
516
+ def convert_preamble(node)
517
+ return unless add_chapter(node).nil?
518
+
519
+ if ((first_block = node.blocks[0]) && first_block.style == 'abstract') ||
520
+ # REVIEW: should we treat the preamble as an abstract in general?
521
+ (first_block && node.blocks.size == 1)
522
+ convert_abstract first_block
523
+ else
524
+ node.content
483
525
  end
484
526
  end
485
527
 
486
- def convert_open node
528
+ def convert_open(node)
487
529
  id_attr = node.id ? %( id="#{node.id}") : nil
488
530
  class_attr = node.role ? %( class="#{node.role}") : nil
489
531
  if id_attr || class_attr
@@ -495,18 +537,22 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
495
537
  end
496
538
  end
497
539
 
498
- def convert_abstract node
540
+ def convert_abstract(node)
499
541
  %(<div class="abstract" epub:type="preamble">
500
542
  #{output_content node}
501
543
  </div>)
502
544
  end
503
545
 
504
- def convert_paragraph node
546
+ def convert_paragraph(node)
505
547
  id_attr = node.id ? %( id="#{node.id}") : ''
506
548
  role = node.role
507
549
  # stack-head is the alternative to the default, inline-head (where inline means "run-in")
508
550
  head_stop = node.attr 'head-stop', (role && (node.has_role? 'stack-head') ? nil : '.')
509
- head = node.title? ? %(<strong class="head">#{title = node.title}#{head_stop && title !~ TrailingPunctRx ? head_stop : ''}</strong> ) : ''
551
+ head = if node.title?
552
+ %(<strong class="head">#{title = node.title}#{head_stop && title !~ TRAILING_PUNCT_RX ? head_stop : ''}</strong> )
553
+ else
554
+ ''
555
+ end
510
556
  if role
511
557
  node.set_option 'hardbreaks' if node.has_role? 'signature'
512
558
  %(<p#{id_attr} class="#{role}">#{head}#{node.content}</p>)
@@ -515,7 +561,7 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
515
561
  end
516
562
  end
517
563
 
518
- def convert_pass node
564
+ def convert_pass(node)
519
565
  content = node.content
520
566
  if content == '<?hard-pagebreak?>'
521
567
  '<hr epub:type="pagebreak" class="pagebreak"/>'
@@ -524,7 +570,7 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
524
570
  end
525
571
  end
526
572
 
527
- def convert_admonition node
573
+ def convert_admonition(node)
528
574
  id_attr = node.id ? %( id="#{node.id}") : ''
529
575
  if node.title?
530
576
  title = node.title
@@ -547,17 +593,21 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
547
593
  logger.warn %(unknown admonition type: #{type})
548
594
  'notice'
549
595
  end
550
- %(<aside#{id_attr} class="admonition #{type}"#{title_attr} epub:type="#{epub_type}">
596
+ %(<aside#{id_attr} class="admonition #{type}#{(role = node.role) ? " #{role}" : ''}"#{title_attr} epub:type="#{epub_type}">
551
597
  #{title_el}<div class="content">
552
598
  #{output_content node}
553
599
  </div>
554
600
  </aside>)
555
601
  end
556
602
 
557
- def convert_example node
603
+ def convert_example(node)
558
604
  id_attr = node.id ? %( id="#{node.id}") : ''
559
- title_div = node.title? ? %(<div class="example-title">#{node.title}</div>
560
- ) : ''
605
+ title_div = if node.title?
606
+ %(<div class="example-title">#{node.title}</div>
607
+ )
608
+ else
609
+ ''
610
+ end
561
611
  %(<div#{id_attr} class="example">
562
612
  #{title_div}<div class="example-content">
563
613
  #{output_content node}
@@ -565,23 +615,28 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
565
615
  </div>)
566
616
  end
567
617
 
568
- def convert_floating_title node
618
+ def convert_floating_title(node)
569
619
  tag_name = %(h#{node.level + 1})
570
620
  id_attribute = node.id ? %( id="#{node.id}") : ''
571
621
  %(<#{tag_name}#{id_attribute} class="#{['discrete', node.role].compact * ' '}">#{node.title}</#{tag_name}>)
572
622
  end
573
623
 
574
- def convert_listing node
624
+ # @param node [Asciidoctor::Block]
625
+ def convert_listing(node)
575
626
  id_attribute = node.id ? %( id="#{node.id}") : ''
576
627
  nowrap = (node.option? 'nowrap') || !(node.document.attr? 'prewrap')
577
628
  if node.style == 'source'
578
629
  lang = node.attr 'language'
579
630
  syntax_hl = node.document.syntax_highlighter
580
631
  if syntax_hl
581
- opts = syntax_hl.highlight? ? {
582
- css_mode: ((doc_attrs = node.document.attributes)[%(#{syntax_hl.name}-css)] || :class).to_sym,
583
- style: doc_attrs[%(#{syntax_hl.name}-style)],
584
- } : {}
632
+ opts = if syntax_hl.highlight?
633
+ {
634
+ css_mode: ((doc_attrs = node.document.attributes)[%(#{syntax_hl.name}-css)] || :class).to_sym,
635
+ style: doc_attrs[%(#{syntax_hl.name}-style)]
636
+ }
637
+ else
638
+ {}
639
+ end
585
640
  opts[:nowrap] = nowrap
586
641
  else
587
642
  pre_open = %(<pre class="highlight#{nowrap ? ' nowrap' : ''}"><code#{lang ? %( class="language-#{lang}" data-lang="#{lang}") : ''}>)
@@ -600,7 +655,7 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
600
655
  </figure>)
601
656
  end
602
657
 
603
- def convert_stem node
658
+ def convert_stem(node)
604
659
  return convert_listing node if node.style != 'asciimath' || !asciimath_available?
605
660
 
606
661
  id_attr = node.id ? %( id="#{node.id}") : ''
@@ -623,7 +678,7 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
623
678
  Helpers.require_library('asciimath', true, :warn).nil? ? :unavailable : :loaded
624
679
  end
625
680
 
626
- def convert_literal node
681
+ def convert_literal(node)
627
682
  id_attribute = node.id ? %( id="#{node.id}") : ''
628
683
  title_element = node.title? ? %(<figcaption>#{node.captioned_title}</figcaption>) : ''
629
684
  %(<figure#{id_attribute} class="literalblock#{prepend_space node.role}">
@@ -632,15 +687,15 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
632
687
  </figure>)
633
688
  end
634
689
 
635
- def convert_page_break _node
690
+ def convert_page_break(_node)
636
691
  '<hr epub:type="pagebreak" class="pagebreak"/>'
637
692
  end
638
693
 
639
- def convert_thematic_break _node
694
+ def convert_thematic_break(_node)
640
695
  '<hr class="thematicbreak"/>'
641
696
  end
642
697
 
643
- def convert_quote node
698
+ def convert_quote(node)
644
699
  id_attr = node.id ? %( id="#{node.id}") : ''
645
700
  class_attr = (role = node.role) ? %( class="blockquote #{role}") : ' class="blockquote"'
646
701
 
@@ -656,8 +711,12 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
656
711
 
657
712
  footer_content << %(<span class="context">#{node.title}</span>) if node.title?
658
713
 
659
- footer_tag = footer_content.empty? ? '' : %(
714
+ footer_tag = if footer_content.empty?
715
+ ''
716
+ else
717
+ %(
660
718
  <footer>~ #{footer_content * ' '}</footer>)
719
+ end
661
720
  content = (output_content node).strip
662
721
  %(<div#{id_attr}#{class_attr}>
663
722
  <blockquote>
@@ -666,7 +725,7 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
666
725
  </div>)
667
726
  end
668
727
 
669
- def convert_verse node
728
+ def convert_verse(node)
670
729
  id_attr = node.id ? %( id="#{node.id}") : ''
671
730
  class_attr = (role = node.role) ? %( class="verse #{role}") : ' class="verse"'
672
731
 
@@ -680,14 +739,18 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
680
739
  footer_content << %(<cite title="#{citetitle_sanitized}">#{citetitle}</cite>)
681
740
  end
682
741
 
683
- footer_tag = !footer_content.empty? ? %(
684
- <span class="attribution">~ #{footer_content * ', '}</span>) : ''
742
+ footer_tag = if footer_content.empty?
743
+ ''
744
+ else
745
+ %(
746
+ <span class="attribution">~ #{footer_content * ', '}</span>)
747
+ end
685
748
  %(<div#{id_attr}#{class_attr}>
686
749
  <pre>#{node.content}#{footer_tag}</pre>
687
750
  </div>)
688
751
  end
689
752
 
690
- def convert_sidebar node
753
+ def convert_sidebar(node)
691
754
  classes = ['sidebar']
692
755
  if node.title?
693
756
  classes << 'titled'
@@ -707,18 +770,21 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
707
770
  </aside>)
708
771
  end
709
772
 
710
- def convert_table node
773
+ def convert_table(node)
711
774
  lines = [%(<div class="table">)]
712
775
  lines << %(<div class="content">)
713
776
  table_id_attr = node.id ? %( id="#{node.id}") : ''
714
777
  table_classes = [
715
778
  'table',
716
779
  %(table-framed-#{node.attr 'frame', 'rows', 'table-frame'}),
717
- %(table-grid-#{node.attr 'grid', 'rows', 'table-grid'}),
780
+ %(table-grid-#{node.attr 'grid', 'rows', 'table-grid'})
718
781
  ]
719
782
  if (role = node.role)
720
783
  table_classes << role
721
784
  end
785
+ if (float = node.attr 'float')
786
+ table_classes << float
787
+ end
722
788
  table_styles = []
723
789
  if (autowidth = node.option? 'autowidth') && !(node.attr? 'width')
724
790
  table_classes << 'fit-content'
@@ -726,21 +792,21 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
726
792
  table_styles << %(width: #{node.attr 'tablepcwidth'}%;)
727
793
  end
728
794
  table_class_attr = %( class="#{table_classes * ' '}")
729
- table_style_attr = !table_styles.empty? ? %( style="#{table_styles * '; '}") : ''
795
+ table_style_attr = table_styles.empty? ? '' : %( style="#{table_styles * '; '}")
730
796
 
731
797
  lines << %(<table#{table_id_attr}#{table_class_attr}#{table_style_attr}>)
732
798
  lines << %(<caption>#{node.captioned_title}</caption>) if node.title?
733
- if (node.attr 'rowcount') > 0
799
+ if (node.attr 'rowcount').positive?
734
800
  lines << '<colgroup>'
735
801
  if autowidth
736
802
  lines += (Array.new node.columns.size, %(<col/>))
737
803
  else
738
804
  node.columns.each do |col|
739
- lines << ((col.option? 'autowidth') ? %(<col/>) : %(<col style="width: #{col.attr 'colpcwidth'}%;" />))
805
+ lines << (col.option?('autowidth') ? %(<col/>) : %(<col style="width: #{col.attr 'colpcwidth'}%;" />))
740
806
  end
741
807
  end
742
808
  lines << '</colgroup>'
743
- [:head, :body, :foot].reject {|tsec| node.rows[tsec].empty? }.each do |tsec|
809
+ %i[head body foot].reject { |tsec| node.rows[tsec].empty? }.each do |tsec|
744
810
  lines << %(<t#{tsec}>)
745
811
  node.rows[tsec].each do |row|
746
812
  lines << '<tr>'
@@ -766,12 +832,12 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
766
832
  cell_tag_name = tsec == :head || cell.style == :header ? 'th' : 'td'
767
833
  cell_classes = [
768
834
  "halign-#{cell.attr 'halign'}",
769
- "valign-#{cell.attr 'valign'}",
835
+ "valign-#{cell.attr 'valign'}"
770
836
  ]
771
- cell_class_attr = !cell_classes.empty? ? %( class="#{cell_classes * ' '}") : ''
837
+ cell_class_attr = cell_classes.empty? ? '' : %( class="#{cell_classes * ' '}")
772
838
  cell_colspan_attr = cell.colspan ? %( colspan="#{cell.colspan}") : ''
773
839
  cell_rowspan_attr = cell.rowspan ? %( rowspan="#{cell.rowspan}") : ''
774
- cell_style_attr = (node.document.attr? 'cellbgcolor') ? %( style="background-color: #{node.document.attr 'cellbgcolor'}") : ''
840
+ cell_style_attr = node.document.attr?('cellbgcolor') ? %( style="background-color: #{node.document.attr 'cellbgcolor'}") : ''
775
841
  lines << %(<#{cell_tag_name}#{cell_class_attr}#{cell_colspan_attr}#{cell_rowspan_attr}#{cell_style_attr}>#{cell_content}</#{cell_tag_name}>)
776
842
  end
777
843
  lines << '</tr>'
@@ -785,10 +851,10 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
785
851
  lines * LF
786
852
  end
787
853
 
788
- def convert_colist node
854
+ def convert_colist(node)
789
855
  lines = ['<div class="callout-list">
790
856
  <ol>']
791
- num = CalloutStartNum
857
+ num = CALLOUT_START_NUM
792
858
  node.items.each_with_index do |item, i|
793
859
  lines << %(<li><i class="conum" data-value="#{i + 1}">#{num}</i> #{item.text}</li>)
794
860
  num = num.next
@@ -798,7 +864,7 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
798
864
  end
799
865
 
800
866
  # TODO: add complex class if list has nested blocks
801
- def convert_dlist node
867
+ def convert_dlist(node)
802
868
  lines = []
803
869
  id_attribute = node.id ? %( id="#{node.id}") : ''
804
870
 
@@ -806,7 +872,7 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
806
872
  when 'horizontal'
807
873
  ['hdlist', node.role]
808
874
  when 'itemized', 'ordered'
809
- # QUESTION should we just use itemized-list and ordered-list as the class here? or just list?
875
+ # QUESTION: should we just use itemized-list and ordered-list as the class here? or just list?
810
876
  ['dlist', %(#{node.style}-list), node.role]
811
877
  else
812
878
  ['description-list']
@@ -822,13 +888,13 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
822
888
  list_tag_name = style == 'itemized' ? 'ul' : 'ol'
823
889
  role = node.role
824
890
  subject_stop = node.attr 'subject-stop', (role && (node.has_role? 'stack') ? nil : ':')
825
- list_class_attr = (node.option? 'brief') ? ' class="brief"' : ''
891
+ list_class_attr = node.option?('brief') ? ' class="brief"' : ''
826
892
  lines << %(<#{list_tag_name}#{list_class_attr}#{list_tag_name == 'ol' && (node.option? 'reversed') ? ' reversed="reversed"' : ''}>)
827
893
  node.items.each do |subjects, dd|
828
894
  # consists of one term (a subject) and supporting content
829
- subject = [*subjects].first.text
895
+ subject = Array(subjects).first.text
830
896
  subject_plain = xml_sanitize subject, :plain
831
- subject_element = %(<strong class="subject">#{subject}#{subject_stop && subject_plain !~ TrailingPunctRx ? subject_stop : ''}</strong>)
897
+ subject_element = %(<strong class="subject">#{subject}#{subject_stop && subject_plain !~ TRAILING_PUNCT_RX ? subject_stop : ''}</strong>)
832
898
  lines << '<li>'
833
899
  if dd
834
900
  # NOTE: must wrap remaining text in a span to help webkit justify the text properly
@@ -844,15 +910,15 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
844
910
  lines << '<table>'
845
911
  if (node.attr? 'labelwidth') || (node.attr? 'itemwidth')
846
912
  lines << '<colgroup>'
847
- col_style_attribute = (node.attr? 'labelwidth') ? %( style="width: #{(node.attr 'labelwidth').chomp '%'}%;") : ''
913
+ col_style_attribute = node.attr?('labelwidth') ? %( style="width: #{(node.attr 'labelwidth').chomp '%'}%;") : ''
848
914
  lines << %(<col#{col_style_attribute} />)
849
- col_style_attribute = (node.attr? 'itemwidth') ? %( style="width: #{(node.attr 'itemwidth').chomp '%'}%;") : ''
915
+ col_style_attribute = node.attr?('itemwidth') ? %( style="width: #{(node.attr 'itemwidth').chomp '%'}%;") : ''
850
916
  lines << %(<col#{col_style_attribute} />)
851
917
  lines << '</colgroup>'
852
918
  end
853
919
  node.items.each do |terms, dd|
854
920
  lines << '<tr>'
855
- lines << %(<td class="hdlist1#{(node.option? 'strong') ? ' strong' : ''}">)
921
+ lines << %(<td class="hdlist1#{node.option?('strong') ? ' strong' : ''}">)
856
922
  first_term = true
857
923
  terms.each do |dt|
858
924
  lines << %(<br />) unless first_term
@@ -874,12 +940,13 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
874
940
  else
875
941
  lines << '<dl>'
876
942
  node.items.each do |terms, dd|
877
- [*terms].each do |dt|
943
+ Array(terms).each do |dt|
878
944
  lines << %(<dt>
879
945
  <span class="term">#{dt.text}</span>
880
946
  </dt>)
881
947
  end
882
948
  next unless dd
949
+
883
950
  lines << '<dd>'
884
951
  if dd.blocks?
885
952
  lines << %(<span class="principal">#{dd.text}</span>) if dd.text?
@@ -896,22 +963,22 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
896
963
  lines * LF
897
964
  end
898
965
 
899
- def convert_olist node
966
+ def convert_olist(node)
900
967
  complex = false
901
968
  div_classes = ['ordered-list', node.style, node.role].compact
902
- ol_classes = [node.style, ((node.option? 'brief') ? 'brief' : nil)].compact
969
+ ol_classes = [node.style, (node.option?('brief') ? 'brief' : nil)].compact
903
970
  ol_class_attr = ol_classes.empty? ? '' : %( class="#{ol_classes * ' '}")
904
- ol_start_attr = (node.attr? 'start') ? %( start="#{node.attr 'start'}") : ''
971
+ ol_start_attr = node.attr?('start') ? %( start="#{node.attr 'start'}") : ''
905
972
  id_attribute = node.id ? %( id="#{node.id}") : ''
906
973
  lines = [%(<div#{id_attribute} class="#{div_classes * ' '}">)]
907
974
  lines << %(<h3 class="list-heading">#{node.title}</h3>) if node.title?
908
- lines << %(<ol#{ol_class_attr}#{ol_start_attr}#{(node.option? 'reversed') ? ' reversed="reversed"' : ''}>)
975
+ lines << %(<ol#{ol_class_attr}#{ol_start_attr}#{node.option?('reversed') ? ' reversed="reversed"' : ''}>)
909
976
  node.items.each do |item|
910
977
  lines << %(<li>
911
978
  <span class="principal">#{item.text}</span>)
912
979
  if item.blocks?
913
980
  lines << item.content
914
- complex = true unless item.blocks.size == 1 && ::Asciidoctor::List === item.blocks[0]
981
+ complex = true unless item.blocks.size == 1 && item.blocks[0].is_a?(::Asciidoctor::List)
915
982
  end
916
983
  lines << '</li>'
917
984
  end
@@ -924,10 +991,10 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
924
991
  lines * LF
925
992
  end
926
993
 
927
- def convert_ulist node
994
+ def convert_ulist(node)
928
995
  complex = false
929
996
  div_classes = ['itemized-list', node.style, node.role].compact
930
- ul_classes = [node.style, ((node.option? 'brief') ? 'brief' : nil)].compact
997
+ ul_classes = [node.style, (node.option?('brief') ? 'brief' : nil)].compact
931
998
  ul_class_attr = ul_classes.empty? ? '' : %( class="#{ul_classes * ' '}")
932
999
  id_attribute = node.id ? %( id="#{node.id}") : ''
933
1000
  lines = [%(<div#{id_attribute} class="#{div_classes * ' '}">)]
@@ -938,7 +1005,7 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
938
1005
  <span class="principal">#{item.text}</span>)
939
1006
  if item.blocks?
940
1007
  lines << item.content
941
- complex = true unless item.blocks.size == 1 && ::Asciidoctor::List === item.blocks[0]
1008
+ complex = true unless item.blocks.size == 1 && item.blocks[0].is_a?(::Asciidoctor::List)
942
1009
  end
943
1010
  lines << '</li>'
944
1011
  end
@@ -951,22 +1018,23 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
951
1018
  lines * LF
952
1019
  end
953
1020
 
954
- def doc_option document, key
1021
+ def doc_option(document, key)
955
1022
  loop do
956
1023
  value = document.options[key]
957
1024
  return value unless value.nil?
1025
+
958
1026
  document = document.parent_document
959
1027
  break if document.nil?
960
1028
  end
961
1029
  nil
962
1030
  end
963
1031
 
964
- def root_document document
1032
+ def root_document(document)
965
1033
  document = document.parent_document until document.parent_document.nil?
966
1034
  document
967
1035
  end
968
1036
 
969
- def register_media_file node, target, media_type
1037
+ def register_media_file(node, target, media_type)
970
1038
  if target.end_with?('.svg') || target.start_with?('data:image/svg+xml')
971
1039
  chapter = get_enclosing_chapter node
972
1040
  chapter.set_attr 'epub-properties', [] unless chapter.attr? 'epub-properties'
@@ -991,9 +1059,14 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
991
1059
  @media_files[target] ||= { path: fs_path, media_type: media_type }
992
1060
  end
993
1061
 
994
- def resolve_image_attrs node
1062
+ # @param node [Asciidoctor::Block]
1063
+ # @return [Array<String>]
1064
+ def resolve_image_attrs(node)
995
1065
  img_attrs = []
996
- img_attrs << %(alt="#{node.attr 'alt'}") if node.attr? 'alt'
1066
+
1067
+ unless (alt = encode_attribute_value(node.alt)).empty?
1068
+ img_attrs << %(alt="#{alt}")
1069
+ end
997
1070
 
998
1071
  # Unlike browsers, Calibre/Kindle *do* scale image if only height is specified
999
1072
  # So, in order to match browser behavior, we just always omit height
@@ -1004,33 +1077,33 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1004
1077
  # HTML5 spec (and EPUBCheck) only allows pixels in width, but browsers also accept percents
1005
1078
  # and there are multiple AsciiDoc files in the wild that have width=percents%
1006
1079
  # So, for compatibility reasons, output percentage width as a CSS style
1007
- if width[/^\d+%$/]
1008
- img_attrs << %(style="width: #{width}")
1009
- else
1010
- img_attrs << %(width="#{width}")
1011
- end
1080
+ img_attrs << if width[/^\d+%$/]
1081
+ %(style="width: #{width}")
1082
+ else
1083
+ %(width="#{width}")
1084
+ end
1012
1085
  end
1013
1086
 
1014
1087
  img_attrs
1015
1088
  end
1016
1089
 
1017
- def convert_audio node
1090
+ def convert_audio(node)
1018
1091
  id_attr = node.id ? %( id="#{node.id}") : ''
1019
1092
  target = node.media_uri node.attr 'target'
1020
1093
  register_media_file node, target, 'audio'
1021
1094
  title_element = node.title? ? %(\n<figcaption>#{node.captioned_title}</figcaption>) : ''
1022
1095
 
1023
- autoplay_attr = (node.option? 'autoplay') ? ' autoplay="autoplay"' : ''
1024
- controls_attr = (node.option? 'nocontrols') ? '' : ' controls="controls"'
1025
- loop_attr = (node.option? 'loop') ? ' loop="loop"' : ''
1096
+ autoplay_attr = node.option?('autoplay') ? ' autoplay="autoplay"' : ''
1097
+ controls_attr = node.option?('nocontrols') ? '' : ' controls="controls"'
1098
+ loop_attr = node.option?('loop') ? ' loop="loop"' : ''
1026
1099
 
1027
1100
  start_t = node.attr 'start'
1028
1101
  end_t = node.attr 'end'
1029
- if start_t || end_t
1030
- time_anchor = %(#t=#{start_t || ''}#{end_t ? ",#{end_t}" : ''})
1031
- else
1032
- time_anchor = ''
1033
- end
1102
+ time_anchor = if start_t || end_t
1103
+ %(#t=#{start_t || ''}#{end_t ? ",#{end_t}" : ''})
1104
+ else
1105
+ ''
1106
+ end
1034
1107
 
1035
1108
  %(<figure#{id_attr} class="audioblock#{prepend_space node.role}">#{title_element}
1036
1109
  <div class="content">
@@ -1042,25 +1115,25 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1042
1115
  end
1043
1116
 
1044
1117
  # TODO: Support multiple video files in different formats for a single video
1045
- def convert_video node
1118
+ def convert_video(node)
1046
1119
  id_attr = node.id ? %( id="#{node.id}") : ''
1047
1120
  target = node.media_uri node.attr 'target'
1048
1121
  register_media_file node, target, 'video'
1049
1122
  title_element = node.title? ? %(\n<figcaption>#{node.captioned_title}</figcaption>) : ''
1050
1123
 
1051
- width_attr = (node.attr? 'width') ? %( width="#{node.attr 'width'}") : ''
1052
- height_attr = (node.attr? 'height') ? %( height="#{node.attr 'height'}") : ''
1053
- autoplay_attr = (node.option? 'autoplay') ? ' autoplay="autoplay"' : ''
1054
- controls_attr = (node.option? 'nocontrols') ? '' : ' controls="controls"'
1055
- loop_attr = (node.option? 'loop') ? ' loop="loop"' : ''
1124
+ width_attr = node.attr?('width') ? %( width="#{node.attr 'width'}") : ''
1125
+ height_attr = node.attr?('height') ? %( height="#{node.attr 'height'}") : ''
1126
+ autoplay_attr = node.option?('autoplay') ? ' autoplay="autoplay"' : ''
1127
+ controls_attr = node.option?('nocontrols') ? '' : ' controls="controls"'
1128
+ loop_attr = node.option?('loop') ? ' loop="loop"' : ''
1056
1129
 
1057
1130
  start_t = node.attr 'start'
1058
1131
  end_t = node.attr 'end'
1059
- if start_t || end_t
1060
- time_anchor = %(#t=#{start_t || ''}#{end_t ? ",#{end_t}" : ''})
1061
- else
1062
- time_anchor = ''
1063
- end
1132
+ time_anchor = if start_t || end_t
1133
+ %(#t=#{start_t || ''}#{end_t ? ",#{end_t}" : ''})
1134
+ else
1135
+ ''
1136
+ end
1064
1137
 
1065
1138
  if (poster = node.attr 'poster').nil_or_empty?
1066
1139
  poster_attr = ''
@@ -1070,7 +1143,7 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1070
1143
  poster_attr = %( poster="#{poster}")
1071
1144
  end
1072
1145
 
1073
- %(<figure#{id_attr} class="video#{prepend_space node.role}">#{title_element}
1146
+ %(<figure#{id_attr} class="video#{prepend_space node.role}#{prepend_space node.attr('float')}">#{title_element}
1074
1147
  <div class="content">
1075
1148
  <video src="#{target}#{time_anchor}"#{width_attr}#{height_attr}#{autoplay_attr}#{poster_attr}#{controls_attr}#{loop_attr}>
1076
1149
  <div>Your Reading System does not support (this) video.</div>
@@ -1079,35 +1152,41 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1079
1152
  </figure>)
1080
1153
  end
1081
1154
 
1082
- def convert_image node
1155
+ # @param node [Asciidoctor::Block]
1156
+ # @return [String]
1157
+ def convert_image(node)
1083
1158
  target = node.image_uri node.attr 'target'
1084
1159
  register_media_file node, target, 'image'
1085
1160
  id_attr = node.id ? %( id="#{node.id}") : ''
1086
1161
  title_element = node.title? ? %(\n<figcaption>#{node.captioned_title}</figcaption>) : ''
1087
1162
  img_attrs = resolve_image_attrs node
1088
- %(<figure#{id_attr} class="image#{prepend_space node.role}">
1163
+ %(<figure#{id_attr} class="image#{prepend_space node.role}#{prepend_space node.attr('float')}">
1089
1164
  <div class="content">
1090
1165
  <img src="#{target}"#{prepend_space img_attrs * ' '} />
1091
1166
  </div>#{title_element}
1092
1167
  </figure>)
1093
1168
  end
1094
1169
 
1095
- def get_enclosing_chapter node
1170
+ def get_enclosing_chapter(node)
1096
1171
  loop do
1097
1172
  return nil if node.nil?
1098
- return node unless get_chapter_name(node).nil?
1173
+ return node unless get_chapter_filename(node).nil?
1174
+
1099
1175
  node = node.parent
1100
1176
  end
1101
1177
  end
1102
1178
 
1103
- def convert_inline_anchor node
1179
+ def convert_inline_anchor(node)
1104
1180
  case node.type
1105
1181
  when :xref
1106
- doc, refid, target, text = node.document, node.attr('refid'), node.target, node.text
1182
+ doc = node.document
1183
+ refid = node.attr('refid')
1184
+ target = node.target
1185
+ text = node.text
1107
1186
  id_attr = ''
1108
1187
 
1109
1188
  if (path = node.attributes['path'])
1110
- # NOTE non-nil path indicates this is an inter-document xref that's not included in current document
1189
+ # NOTE: non-nil path indicates this is an inter-document xref that's not included in current document
1111
1190
  text = node.text || path
1112
1191
  elsif refid == '#'
1113
1192
  logger.warn %(#{::File.basename doc.attr('docfile')}: <<chapter#>> xref syntax isn't supported anymore. Use either <<chapter>> or <<chapter#anchor>>)
@@ -1116,7 +1195,7 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1116
1195
  our_chapter = get_enclosing_chapter node
1117
1196
  ref_chapter = get_enclosing_chapter ref
1118
1197
  if ref_chapter
1119
- ref_docname = get_chapter_name ref_chapter
1198
+ ref_docname = get_chapter_filename ref_chapter
1120
1199
  if ref_chapter == our_chapter
1121
1200
  # ref within same chapter file
1122
1201
  id_attr = %( id="xref-#{refid}")
@@ -1140,12 +1219,12 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1140
1219
 
1141
1220
  %(<a#{id_attr} href="#{target}" class="xref">#{text || "[#{refid}]"}</a>)
1142
1221
  when :ref
1143
- # NOTE id is used instead of target starting in Asciidoctor 2.0.0
1222
+ # NOTE: id is used instead of target starting in Asciidoctor 2.0.0
1144
1223
  %(<a id="#{node.target || node.id}"></a>)
1145
1224
  when :link
1146
1225
  %(<a href="#{node.target}" class="link">#{node.text}</a>)
1147
1226
  when :bibref
1148
- # NOTE reftext is no longer enclosed in [] starting in Asciidoctor 2.0.0
1227
+ # NOTE: reftext is no longer enclosed in [] starting in Asciidoctor 2.0.0
1149
1228
  # NOTE id is used instead of target starting in Asciidoctor 2.0.0
1150
1229
  if (reftext = node.reftext)
1151
1230
  reftext = %([#{reftext}]) unless reftext.start_with? '['
@@ -1159,30 +1238,37 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1159
1238
  end
1160
1239
  end
1161
1240
 
1162
- def convert_inline_break node
1241
+ def convert_inline_break(node)
1163
1242
  %(#{node.text}<br/>)
1164
1243
  end
1165
1244
 
1166
- def convert_inline_button node
1167
- %(<b class="button">[<span class="label">#{node.text}</span>]</b>)
1245
+ # @param node [Asciidoctor::Inline]
1246
+ # @return [String]
1247
+ def convert_inline_button(node)
1248
+ %(<b class="button">#{node.text}</b>)
1168
1249
  end
1169
1250
 
1170
- def convert_inline_callout node
1171
- num = CalloutStartNum
1251
+ def convert_inline_callout(node)
1252
+ num = CALLOUT_START_NUM
1172
1253
  int_num = node.text.to_i
1173
1254
  (int_num - 1).times { num = num.next }
1174
1255
  %(<i class="conum" data-value="#{int_num}">#{num}</i>)
1175
1256
  end
1176
1257
 
1177
- def convert_inline_footnote node
1258
+ # @param node [Asciidoctor::Inline]
1259
+ # @return [String]
1260
+ def convert_inline_footnote(node)
1178
1261
  if (index = node.attr 'index')
1179
- %(<sup class="noteref">[<a id="noteref-#{index}" href="#note-#{index}" epub:type="noteref">#{index}</a>]</sup>)
1262
+ attrs = []
1263
+ attrs << %(id="#{node.id}") if node.id
1264
+
1265
+ %(<sup class="noteref">[<a#{prepend_space attrs * ' '}href="#note-#{index}" epub:type="noteref">#{index}</a>]</sup>)
1180
1266
  elsif node.type == :xref
1181
1267
  %(<mark class="noteref" title="Unresolved note reference">#{node.text}</mark>)
1182
1268
  end
1183
1269
  end
1184
1270
 
1185
- def convert_inline_image node
1271
+ def convert_inline_image(node)
1186
1272
  if node.type == 'icon'
1187
1273
  icon_names << (icon_name = node.target)
1188
1274
  i_classes = ['icon', %(i-#{icon_name})]
@@ -1190,36 +1276,37 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1190
1276
  i_classes << %(icon-flip-#{(node.attr 'flip')[0]}) if node.attr? 'flip'
1191
1277
  i_classes << %(icon-rotate-#{node.attr 'rotate'}) if node.attr? 'rotate'
1192
1278
  i_classes << node.role if node.role?
1279
+ i_classes << node.attr('float') if node.attr 'float'
1193
1280
  %(<i class="#{i_classes * ' '}"></i>)
1194
1281
  else
1195
1282
  target = node.image_uri node.target
1196
1283
  register_media_file node, target, 'image'
1197
1284
 
1198
1285
  img_attrs = resolve_image_attrs node
1199
- img_attrs << %(class="inline#{prepend_space node.role}")
1286
+ img_attrs << %(class="inline#{prepend_space node.role}#{prepend_space node.attr('float')}")
1200
1287
  %(<img src="#{target}"#{prepend_space img_attrs * ' '}/>)
1201
1288
  end
1202
1289
  end
1203
1290
 
1204
- def convert_inline_indexterm node
1291
+ def convert_inline_indexterm(node)
1205
1292
  node.type == :visible ? node.text : ''
1206
1293
  end
1207
1294
 
1208
- def convert_inline_kbd node
1295
+ def convert_inline_kbd(node)
1209
1296
  if (keys = node.attr 'keys').size == 1
1210
1297
  %(<kbd>#{keys[0]}</kbd>)
1211
1298
  else
1212
- key_combo = keys.map {|key| %(<kbd>#{key}</kbd>) }.join '+'
1299
+ key_combo = keys.map { |key| %(<kbd>#{key}</kbd>) }.join '+'
1213
1300
  %(<span class="keyseq">#{key_combo}</span>)
1214
1301
  end
1215
1302
  end
1216
1303
 
1217
- def convert_inline_menu node
1304
+ def convert_inline_menu(node)
1218
1305
  menu = node.attr 'menu'
1219
- # NOTE we swap right angle quote with chevron right from FontAwesome using CSS
1220
- caret = %(#{NoBreakSpace}<span class="caret">#{RightAngleQuote}</span> )
1306
+ # NOTE: we swap right angle quote with chevron right from FontAwesome using CSS
1307
+ caret = %(#{NO_BREAK_SPACE}<span class="caret">#{RIGHT_ANGLE_QUOTE}</span> )
1221
1308
  if !(submenus = node.attr 'submenus').empty?
1222
- submenu_path = submenus.map {|submenu| %(<span class="submenu">#{submenu}</span>#{caret}) }.join.chop
1309
+ submenu_path = submenus.map { |submenu| %(<span class="submenu">#{submenu}</span>#{caret}) }.join.chop
1223
1310
  %(<span class="menuseq"><span class="menu">#{menu}</span>#{caret}#{submenu_path} <span class="menuitem">#{node.attr 'menuitem'}</span></span>)
1224
1311
  elsif (menuitem = node.attr 'menuitem')
1225
1312
  %(<span class="menuseq"><span class="menu">#{menu}</span>#{caret}<span class="menuitem">#{menuitem}</span></span>)
@@ -1228,16 +1315,16 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1228
1315
  end
1229
1316
  end
1230
1317
 
1231
- def convert_inline_quoted node
1318
+ def convert_inline_quoted(node)
1232
1319
  open, close, tag = QUOTE_TAGS[node.type]
1233
1320
 
1234
- if node.type == :asciimath && asciimath_available?
1235
- content = AsciiMath.parse(node.text).to_mathml 'mml:'
1236
- else
1237
- content = node.text
1238
- end
1321
+ content = if node.type == :asciimath && asciimath_available?
1322
+ AsciiMath.parse(node.text).to_mathml 'mml:'
1323
+ else
1324
+ node.text
1325
+ end
1239
1326
 
1240
- node.add_role 'literal' if [:monospaced, :asciimath, :latexmath].include? node.type
1327
+ node.add_role 'literal' if %i[monospaced asciimath latexmath].include? node.type
1241
1328
 
1242
1329
  if node.id
1243
1330
  class_attr = class_string node
@@ -1258,16 +1345,24 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1258
1345
  end
1259
1346
  end
1260
1347
 
1261
- def output_content node
1348
+ def output_content(node)
1262
1349
  node.content_model == :simple ? %(<p>#{node.content}</p>) : node.content
1263
1350
  end
1264
1351
 
1352
+ def encode_attribute_value(val)
1353
+ val.gsub '"', '&quot;'
1354
+ end
1355
+
1265
1356
  # FIXME: merge into with xml_sanitize helper
1266
- def xml_sanitize value, target = :attribute
1267
- sanitized = (value.include? '<') ? value.gsub(XmlElementRx, '').strip.tr_s(' ', ' ') : value
1357
+ def xml_sanitize(value, target = :attribute)
1358
+ sanitized = value.include?('<') ? value.gsub(XML_ELEMENT_RX, '').strip.tr_s(' ', ' ') : value
1268
1359
  if target == :plain && (sanitized.include? ';')
1269
- sanitized = sanitized.gsub(CharEntityRx) { [$1.to_i].pack 'U*' } if sanitized.include? '&#'
1270
- sanitized = sanitized.gsub FromHtmlSpecialCharsRx, FromHtmlSpecialCharsMap
1360
+ if sanitized.include? '&#'
1361
+ sanitized = sanitized.gsub(CHAR_ENTITY_RX) do
1362
+ [::Regexp.last_match(1).to_i].pack 'U*'
1363
+ end
1364
+ end
1365
+ sanitized = sanitized.gsub FROM_HTML_SPECIAL_CHARS_RX, FROM_HTML_SPECIAL_CHARS_MAP
1271
1366
  elsif target == :attribute
1272
1367
  sanitized = sanitized.gsub '"', '&quot;' if sanitized.include? '"'
1273
1368
  end
@@ -1275,8 +1370,9 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1275
1370
  end
1276
1371
 
1277
1372
  # TODO: make check for last content paragraph a feature of Asciidoctor
1278
- def mark_last_paragraph root
1373
+ def mark_last_paragraph(root)
1279
1374
  return unless (last_block = root.blocks[-1])
1375
+
1280
1376
  last_block = last_block.blocks[-1] while last_block.context == :section && last_block.blocks?
1281
1377
  if last_block.context == :paragraph
1282
1378
  last_block.attributes['role'] = last_block.role? ? %(#{last_block.role} last) : 'last'
@@ -1285,11 +1381,11 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1285
1381
  end
1286
1382
 
1287
1383
  # Prepend a space to the value if it's non-nil, otherwise return empty string.
1288
- def prepend_space value
1384
+ def prepend_space(value)
1289
1385
  value ? %( #{value}) : ''
1290
1386
  end
1291
1387
 
1292
- def add_theme_assets doc
1388
+ def add_theme_assets(doc)
1293
1389
  format = @format
1294
1390
  workdir = if doc.attr? 'epub3-stylesdir'
1295
1391
  stylesdir = doc.attr 'epub3-stylesdir'
@@ -1306,42 +1402,46 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1306
1402
  end
1307
1403
 
1308
1404
  # TODO: improve design/UX of custom theme functionality, including custom fonts
1309
-
1310
- if format == :kf8
1311
- # NOTE add layer of indirection so Kindle Direct Publishing (KDP) doesn't strip font-related CSS rules
1312
- @book.add_item 'styles/epub3.css', content: '@import url("epub3-proxied.css");'.to_ios
1313
- @book.add_item 'styles/epub3-css3-only.css', content: '@import url("epub3-css3-only-proxied.css");'.to_ios
1314
- @book.add_item 'styles/epub3-proxied.css', content: (postprocess_css_file ::File.join(workdir, 'epub3.css'), format)
1315
- @book.add_item 'styles/epub3-css3-only-proxied.css', content: (postprocess_css_file ::File.join(workdir, 'epub3-css3-only.css'), format)
1316
- else
1317
- @book.add_item 'styles/epub3.css', content: (postprocess_css_file ::File.join(workdir, 'epub3.css'), format)
1318
- @book.add_item 'styles/epub3-css3-only.css', content: (postprocess_css_file ::File.join(workdir, 'epub3-css3-only.css'), format)
1405
+ %w[epub3 epub3-css3-only].each do |f|
1406
+ css = load_css_file File.join(workdir, %(#{f}.scss))
1407
+ if format == :kf8
1408
+ # NOTE: add layer of indirection so Kindle Direct Publishing (KDP) doesn't strip font-related CSS rules
1409
+ @book.add_item %(styles/#{f}.css), content: %(@import url("#{f}-proxied.css");).to_ios
1410
+ @book.add_item %(styles/#{f}-proxied.css), content: css.to_ios
1411
+ else
1412
+ @book.add_item %(styles/#{f}.css), content: css.to_ios
1413
+ end
1319
1414
  end
1320
1415
 
1321
1416
  syntax_hl = doc.syntax_highlighter
1322
1417
  if syntax_hl&.write_stylesheet? doc
1323
1418
  Dir.mktmpdir do |dir|
1324
1419
  syntax_hl.write_stylesheet doc, dir
1325
- Pathname.glob(dir + '/**/*').map do |filename|
1420
+ Pathname.glob("#{dir}/**/*").map do |filename|
1326
1421
  # Workaround for https://github.com/skoji/gepub/pull/117
1422
+ next unless filename.file?
1423
+
1327
1424
  filename.open do |f|
1328
1425
  @book.add_item filename.basename.to_s, content: f
1329
- end if filename.file?
1426
+ end
1330
1427
  end
1331
1428
  end
1332
1429
  end
1333
1430
 
1334
- font_files, font_css = select_fonts ::File.join(DATA_DIR, 'styles/epub3-fonts.css'), (doc.attr 'scripts', 'latin')
1335
- @book.add_item 'styles/epub3-fonts.css', content: font_css
1431
+ font_files, font_css = select_fonts load_css_file(File.join(DATA_DIR, 'styles/epub3-fonts.scss')),
1432
+ (doc.attr 'scripts', 'latin')
1433
+ @book.add_item 'styles/epub3-fonts.css', content: font_css.to_ios
1336
1434
  unless font_files.empty?
1337
- # NOTE metadata property in oepbs package manifest doesn't work; must use proprietary iBooks file instead
1338
- #(@book.metadata.add_metadata 'meta', 'true')['property'] = 'ibooks:specified-fonts' unless format == :kf8
1339
- @book.add_optional_file 'META-INF/com.apple.ibooks.display-options.xml', '<?xml version="1.0" encoding="UTF-8"?>
1435
+ # NOTE: metadata property in oepbs package manifest doesn't work; must use proprietary iBooks file instead
1436
+ # (@book.metadata.add_metadata 'meta', 'true')['property'] = 'ibooks:specified-fonts' unless format == :kf8
1437
+ unless format == :kf8
1438
+ @book.add_optional_file 'META-INF/com.apple.ibooks.display-options.xml', '<?xml version="1.0" encoding="UTF-8"?>
1340
1439
  <display_options>
1341
1440
  <platform name="*">
1342
1441
  <option name="specified-fonts">true</option>
1343
1442
  </platform>
1344
- </display_options>'.to_ios unless format == :kf8
1443
+ </display_options>'.to_ios
1444
+ end
1345
1445
 
1346
1446
  font_files.each do |font_file|
1347
1447
  @book.add_item font_file, content: File.join(DATA_DIR, font_file)
@@ -1350,7 +1450,10 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1350
1450
  nil
1351
1451
  end
1352
1452
 
1353
- def add_cover_page doc, name
1453
+ # @param doc [Asciidoctor::Document]
1454
+ # @param name [String]
1455
+ # @return [GEPUB::Item, nil]
1456
+ def add_cover_page(doc, name)
1354
1457
  image_attr_name = %(#{name}-image)
1355
1458
 
1356
1459
  return nil if (image_path = doc.attr image_attr_name).nil?
@@ -1359,10 +1462,14 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1359
1462
  imagesdir = (imagesdir == '.' ? '' : %(#{imagesdir}/))
1360
1463
 
1361
1464
  image_attrs = {}
1362
- if (image_path.include? ':') && image_path =~ ImageMacroRx
1465
+ if (image_path.include? ':') && image_path =~ IMAGE_MACRO_RX
1363
1466
  logger.warn %(deprecated block macro syntax detected in :#{image_attr_name}: attribute) if image_path.start_with? 'image::'
1364
- image_path = %(#{imagesdir}#{$1})
1365
- (::Asciidoctor::AttributeList.new $2).parse_into image_attrs, %w(alt width height) unless $2.empty?
1467
+ image_path = %(#{imagesdir}#{::Regexp.last_match(1)})
1468
+ unless ::Regexp.last_match(2).empty?
1469
+ (::Asciidoctor::AttributeList.new ::Regexp.last_match(2)).parse_into image_attrs,
1470
+ %w[alt width
1471
+ height]
1472
+ end
1366
1473
  end
1367
1474
 
1368
1475
  image_href = %(#{imagesdir}jacket/#{name}#{::File.extname image_path})
@@ -1370,9 +1477,11 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1370
1477
  workdir = doc.attr 'docdir'
1371
1478
  workdir = '.' if workdir.nil_or_empty?
1372
1479
 
1480
+ image_path = File.join workdir, image_path unless File.absolute_path? image_path
1481
+
1373
1482
  begin
1374
- @book.add_item(image_href, content: File.join(workdir, image_path)).cover_image
1375
- rescue => e
1483
+ @book.add_item(image_href, content: image_path).cover_image
1484
+ rescue StandardError => e
1376
1485
  logger.error %(#{::File.basename doc.attr('docfile')}: error adding cover image. Make sure that :#{image_attr_name}: attribute points to a valid image file. #{e})
1377
1486
  return nil
1378
1487
  end
@@ -1380,14 +1489,15 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
1380
1489
  return nil if @format == :kf8
1381
1490
 
1382
1491
  unless !image_attrs.empty? && (width = image_attrs['width']) && (height = image_attrs['height'])
1383
- width, height = 1050, 1600
1492
+ width = 1050
1493
+ height = 1600
1384
1494
  end
1385
1495
 
1386
- # NOTE SVG wrapper maintains aspect ratio and confines image to view box
1387
- content = %(<!DOCTYPE html>
1496
+ # NOTE: SVG wrapper maintains aspect ratio and confines image to view box
1497
+ content = %(<?xml version='1.0' encoding='utf-8'?>
1498
+ <!DOCTYPE html>
1388
1499
  <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en" lang="en">
1389
1500
  <head>
1390
- <meta charset="UTF-8"/>
1391
1501
  <title>#{sanitize_doctitle_xml doc, :cdata}</title>
1392
1502
  <style type="text/css">
1393
1503
  @page {
@@ -1417,7 +1527,7 @@ body > svg {
1417
1527
  @book.add_ordered_item %(#{name}.xhtml), content: content, id: name
1418
1528
  end
1419
1529
 
1420
- def get_frontmatter_files doc, workdir
1530
+ def get_frontmatter_files(doc, workdir)
1421
1531
  if doc.attr? 'epub3-frontmatterdir'
1422
1532
  fmdir = doc.attr 'epub3-frontmatterdir'
1423
1533
  fmglob = 'front-matter.*\.html'
@@ -1426,7 +1536,7 @@ body > svg {
1426
1536
  logger.warn %(#{File.basename doc.attr('docfile')}: directory specified by 'epub3-frontmattderdir' doesn't exist! Ignoring ...)
1427
1537
  return []
1428
1538
  end
1429
- fms = Dir.entries(fm_path).delete_if {|x| !x.match fmglob }.sort.map {|y| File.join fm_path, y }
1539
+ fms = Dir.entries(fm_path).delete_if { |x| !x.match fmglob }.sort.map { |y| File.join fm_path, y }
1430
1540
  if fms && !fms.empty?
1431
1541
  fms
1432
1542
  else
@@ -1440,7 +1550,7 @@ body > svg {
1440
1550
  end
1441
1551
  end
1442
1552
 
1443
- def add_front_matter_page doc
1553
+ def add_front_matter_page(doc)
1444
1554
  workdir = doc.attr 'docdir'
1445
1555
  workdir = '.' if workdir.nil_or_empty?
1446
1556
 
@@ -1450,30 +1560,32 @@ body > svg {
1450
1560
 
1451
1561
  front_matter_file = File.basename front_matter, '.html'
1452
1562
  item = @book.add_ordered_item "#{front_matter_file}.xhtml", content: (postprocess_xhtml front_matter_content)
1453
- item.add_property 'svg' if SvgImgSniffRx =~ front_matter_content
1563
+ item.add_property 'svg' if SVG_IMG_SNIFF_RX =~ front_matter_content
1454
1564
  # Store link to first frontmatter page
1455
1565
  result = item if result.nil?
1456
1566
 
1457
- front_matter_content.scan ImgSrcScanRx do
1458
- @book.add_item $1, content: File.join(File.dirname(front_matter), $1)
1567
+ front_matter_content.scan IMAGE_SRC_SCAN_RX do
1568
+ @book.add_item ::Regexp.last_match(1),
1569
+ content: File.join(File.dirname(front_matter), ::Regexp.last_match(1))
1459
1570
  end
1460
1571
  end
1461
1572
 
1462
1573
  result
1463
1574
  end
1464
1575
 
1465
- def add_profile_images doc, usernames
1576
+ def add_profile_images(doc, usernames)
1466
1577
  imagesdir = (doc.attr 'imagesdir', '.').chomp '/'
1467
1578
  imagesdir = (imagesdir == '.' ? nil : %(#{imagesdir}/))
1468
1579
 
1469
1580
  @book.add_item %(#{imagesdir}avatars/default.jpg), content: ::File.join(DATA_DIR, 'images/default-avatar.jpg')
1470
- @book.add_item %(#{imagesdir}headshots/default.jpg), content: ::File.join(DATA_DIR, 'images/default-headshot.jpg')
1581
+ @book.add_item %(#{imagesdir}headshots/default.jpg),
1582
+ content: ::File.join(DATA_DIR, 'images/default-headshot.jpg')
1471
1583
 
1472
- workdir = (workdir = doc.attr 'docdir').nil_or_empty? ? '.' : workdir
1584
+ workdir = '.' if (workdir = doc.attr 'docdir').nil_or_empty?
1473
1585
 
1474
1586
  usernames.each do |username|
1475
1587
  avatar = %(#{imagesdir}avatars/#{username}.jpg)
1476
- if ::File.readable? (resolved_avatar = (::File.join workdir, avatar))
1588
+ if ::File.readable?(resolved_avatar = (::File.join workdir, avatar))
1477
1589
  @book.add_item avatar, content: resolved_avatar
1478
1590
  else
1479
1591
  logger.error %(avatar for #{username} not found or readable: #{avatar}; falling back to default avatar)
@@ -1481,7 +1593,7 @@ body > svg {
1481
1593
  end
1482
1594
 
1483
1595
  headshot = %(#{imagesdir}headshots/#{username}.jpg)
1484
- if ::File.readable? (resolved_headshot = (::File.join workdir, headshot))
1596
+ if ::File.readable?(resolved_headshot = (::File.join workdir, headshot))
1485
1597
  @book.add_item headshot, content: resolved_headshot
1486
1598
  elsif doc.attr? 'builder', 'editions'
1487
1599
  logger.error %(headshot for #{username} not found or readable: #{headshot}; falling back to default headshot)
@@ -1491,11 +1603,12 @@ body > svg {
1491
1603
  nil
1492
1604
  end
1493
1605
 
1494
- def nav_doc doc, items, landmarks, depth
1495
- lines = [%(<!DOCTYPE html>
1496
- <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="#{lang = doc.attr 'lang', 'en'}" lang="#{lang}">
1606
+ def nav_doc(doc, items, landmarks, depth)
1607
+ lines = [%(<?xml version='1.0' encoding='utf-8'?>
1608
+ <!DOCTYPE html>
1609
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="#{lang = doc.attr 'lang',
1610
+ 'en'}" lang="#{lang}">
1497
1611
  <head>
1498
- <meta charset="UTF-8"/>
1499
1612
  <title>#{sanitize_doctitle_xml doc, :cdata}</title>
1500
1613
  <link rel="stylesheet" type="text/css" href="styles/epub3.css"/>
1501
1614
  <link rel="stylesheet" type="text/css" href="styles/epub3-css3-only.css" media="(min-device-width: 0px)"/>
@@ -1527,133 +1640,121 @@ body > svg {
1527
1640
  lines * LF
1528
1641
  end
1529
1642
 
1530
- def nav_level items, depth, state = {}
1643
+ def nav_level(items, depth, state = {})
1531
1644
  lines = []
1532
1645
  lines << '<ol>'
1533
1646
  items.each do |item|
1534
- #index = (state[:index] = (state.fetch :index, 0) + 1)
1535
- if (chapter_name = get_chapter_name item).nil?
1647
+ # index = (state[:index] = (state.fetch :index, 0) + 1)
1648
+ if (chapter_filename = get_chapter_filename item).nil?
1536
1649
  item_label = sanitize_xml get_numbered_title(item), :pcdata
1537
1650
  item_href = %(#{state[:content_doc_href]}##{item.id})
1538
1651
  else
1539
- # NOTE we sanitize the chapter titles because we use formatting to control layout
1540
- if item.context == :document
1541
- item_label = sanitize_doctitle_xml item, :cdata
1542
- else
1543
- item_label = sanitize_xml get_numbered_title(item), :cdata
1544
- end
1545
- item_href = (state[:content_doc_href] = %(#{chapter_name}.xhtml))
1652
+ # NOTE: we sanitize the chapter titles because we use formatting to control layout
1653
+ item_label = if item.context == :document
1654
+ sanitize_doctitle_xml item, :cdata
1655
+ else
1656
+ sanitize_xml get_numbered_title(item), :cdata
1657
+ end
1658
+ item_href = (state[:content_doc_href] = %(#{chapter_filename}.xhtml))
1546
1659
  end
1547
1660
  lines << %(<li><a href="#{item_href}">#{item_label}</a>)
1548
- if depth == 0 || (child_sections = item.sections).empty?
1661
+ if depth.zero? || (child_sections = item.sections).empty?
1549
1662
  lines[-1] = %(#{lines[-1]}</li>)
1550
1663
  else
1551
1664
  lines << (nav_level child_sections, depth - 1, state)
1552
1665
  lines << '</li>'
1553
1666
  end
1554
- state.delete :content_doc_href unless chapter_name.nil?
1667
+ state.delete :content_doc_href unless chapter_filename.nil?
1555
1668
  end
1556
1669
  lines << '</ol>'
1557
1670
  lines * LF
1558
1671
  end
1559
1672
 
1560
- def ncx_doc doc, items, depth
1673
+ def ncx_doc(doc, items, depth)
1561
1674
  # TODO: populate docAuthor element based on unique authors in work
1562
1675
  lines = [%(<?xml version="1.0" encoding="utf-8"?>
1563
1676
  <ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1" xml:lang="#{doc.attr 'lang', 'en'}">
1564
1677
  <head>
1565
1678
  <meta name="dtb:uid" content="#{@book.identifier}"/>
1566
- %{depth}
1679
+ %<depth>s
1567
1680
  <meta name="dtb:totalPageCount" content="0"/>
1568
1681
  <meta name="dtb:maxPageNumber" content="0"/>
1569
1682
  </head>
1570
1683
  <docTitle><text>#{sanitize_doctitle_xml doc, :cdata}</text></docTitle>
1571
1684
  <navMap>)]
1572
1685
  lines << (ncx_level items, depth, state = {})
1573
- lines[0] = lines[0].sub '%{depth}', %(<meta name="dtb:depth" content="#{state[:max_depth]}"/>)
1686
+ lines[0] = lines[0].sub '%<depth>s', %(<meta name="dtb:depth" content="#{state[:max_depth]}"/>)
1574
1687
  lines << %(</navMap>
1575
1688
  </ncx>)
1576
1689
  lines * LF
1577
1690
  end
1578
1691
 
1579
- def ncx_level items, depth, state = {}
1692
+ def ncx_level(items, depth, state = {})
1580
1693
  lines = []
1581
1694
  state[:max_depth] = (state.fetch :max_depth, 0) + 1
1582
1695
  items.each do |item|
1583
1696
  index = (state[:index] = (state.fetch :index, 0) + 1)
1584
1697
  item_id = %(nav_#{index})
1585
- if (chapter_name = get_chapter_name item).nil?
1698
+ if (chapter_filename = get_chapter_filename item).nil?
1586
1699
  item_label = sanitize_xml get_numbered_title(item), :cdata
1587
1700
  item_href = %(#{state[:content_doc_href]}##{item.id})
1588
1701
  else
1589
- if item.context == :document
1590
- item_label = sanitize_doctitle_xml item, :cdata
1591
- else
1592
- item_label = sanitize_xml get_numbered_title(item), :cdata
1593
- end
1594
- item_href = (state[:content_doc_href] = %(#{chapter_name}.xhtml))
1702
+ item_label = if item.context == :document
1703
+ sanitize_doctitle_xml item, :cdata
1704
+ else
1705
+ sanitize_xml get_numbered_title(item), :cdata
1706
+ end
1707
+ item_href = (state[:content_doc_href] = %(#{chapter_filename}.xhtml))
1595
1708
  end
1596
1709
  lines << %(<navPoint id="#{item_id}" playOrder="#{index}">)
1597
1710
  lines << %(<navLabel><text>#{item_label}</text></navLabel>)
1598
1711
  lines << %(<content src="#{item_href}"/>)
1599
- unless depth == 0 || (child_sections = item.sections).empty?
1712
+ unless depth.zero? || (child_sections = item.sections).empty?
1600
1713
  lines << (ncx_level child_sections, depth - 1, state)
1601
1714
  end
1602
1715
  lines << %(</navPoint>)
1603
- state.delete :content_doc_href unless chapter_name.nil?
1716
+ state.delete :content_doc_href unless chapter_filename.nil?
1604
1717
  end
1605
1718
  lines * LF
1606
1719
  end
1607
1720
 
1608
1721
  # Swap fonts in CSS based on the value of the document attribute 'scripts',
1609
1722
  # then return the list of fonts as well as the font CSS.
1610
- def select_fonts filename, scripts = 'latin'
1611
- font_css = ::File.read filename
1723
+ def select_fonts(font_css, scripts = 'latin')
1612
1724
  font_css = font_css.gsub(/(?<=-)latin(?=\.ttf\))/, scripts) unless scripts == 'latin'
1613
1725
 
1614
1726
  # match CSS font urls in the forms of:
1615
1727
  # src: url(../fonts/notoserif-regular-latin.ttf);
1616
1728
  # src: url(../fonts/notoserif-regular-latin.ttf) format("truetype");
1617
- font_list = font_css.scan(/url\(\.\.\/([^)]+\.ttf)\)/).flatten
1618
-
1619
- [font_list, font_css.to_ios]
1620
- end
1729
+ font_list = font_css.scan(%r{url\(\.\./([^)]+?\.ttf)\)}).flatten
1621
1730
 
1622
- def postprocess_css_file filename, format
1623
- return filename unless format == :kf8
1624
- postprocess_css ::File.read(filename), format
1731
+ [font_list, font_css]
1625
1732
  end
1626
1733
 
1627
- def postprocess_css content, format
1628
- return content.to_ios unless format == :kf8
1629
- # TODO: convert regular expressions to constants
1630
- content
1631
- .gsub(/^ -webkit-column-break-.*\n/, '')
1632
- .gsub(/^ max-width: .*\n/, '')
1633
- .to_ios
1734
+ def load_css_file(filename)
1735
+ template = File.read filename
1736
+ load_paths = [File.dirname(filename)]
1737
+ sass_engine = Sass::Engine.new template, syntax: :scss, cache: false, load_paths: load_paths, style: :compressed
1738
+ sass_engine.render
1634
1739
  end
1635
1740
 
1636
- # NOTE Kindle requires that
1637
- # <meta charset="utf-8"/>
1638
- # be converted to
1639
- # <meta http-equiv="Content-Type" content="application/xml+xhtml; charset=UTF-8"/>
1640
- def postprocess_xhtml content
1741
+ def postprocess_xhtml(content)
1641
1742
  return content.to_ios unless @format == :kf8
1743
+
1642
1744
  # TODO: convert regular expressions to constants
1643
1745
  content
1644
- .gsub(/<meta charset="(.+?)"\/>/, '<meta http-equiv="Content-Type" content="application/xml+xhtml; charset=\1"/>')
1645
1746
  .gsub(/<img([^>]+) style="width: (\d\d)%;"/, '<img\1 style="width: \2%; height: \2%;"')
1646
- .gsub(/<script type="text\/javascript">.*?<\/script>\n?/m, '')
1747
+ .gsub(%r{<script type="text/javascript">.*?</script>\n?}m, '')
1647
1748
  .to_ios
1648
1749
  end
1649
1750
 
1650
- def get_kindlegen_command
1751
+ def build_kindlegen_command
1651
1752
  unless @kindlegen_path.nil?
1652
1753
  logger.debug %(Using ebook-kindlegen-path attribute: #{@kindlegen_path})
1653
1754
  return [@kindlegen_path]
1654
1755
  end
1655
1756
 
1656
- unless (result = ENV['KINDLEGEN']).nil?
1757
+ unless (result = ENV.fetch('KINDLEGEN', nil)).nil?
1657
1758
  logger.debug %(Using KINDLEGEN env variable: #{result})
1658
1759
  return [result]
1659
1760
  end
@@ -1669,18 +1770,23 @@ body > svg {
1669
1770
  end
1670
1771
  end
1671
1772
 
1672
- def distill_epub_to_mobi epub_file, target, compress
1673
- mobi_file = ::File.basename target.sub(EpubExtensionRx, '.mobi')
1674
- compress_flag = KindlegenCompression[compress ? (compress.empty? ? '1' : compress.to_s) : '0']
1773
+ def distill_epub_to_mobi(epub_file, target, compress)
1774
+ mobi_file = ::File.basename target.sub(EPUB_EXTENSION_RX, '.mobi')
1775
+ compress_flag = KINDLEGEN_COMPRESSION[if compress
1776
+ compress.empty? ? '1' : compress.to_s
1777
+ else
1778
+ '0'
1779
+ end]
1675
1780
 
1676
- argv = get_kindlegen_command + ['-dont_append_source', compress_flag, '-o', mobi_file, epub_file].compact
1781
+ argv = build_kindlegen_command + ['-dont_append_source', compress_flag, '-o', mobi_file, epub_file].compact
1677
1782
  begin
1678
1783
  # This duplicates Kindlegen.run, but we want to override executable
1679
1784
  out, err, res = Open3.capture3(*argv) do |r|
1680
1785
  r.force_encoding 'UTF-8' if ::Gem.win_platform? && r.respond_to?(:force_encoding)
1681
1786
  end
1682
1787
  rescue Errno::ENOENT => e
1683
- raise 'Unable to run KindleGen. Either install the kindlegen gem or place `kindlegen` executable on PATH or set KINDLEGEN environment variable with path to it', cause: e
1788
+ raise 'Unable to run KindleGen. Either install the kindlegen gem or place `kindlegen` executable on PATH or set KINDLEGEN environment variable with path to it',
1789
+ cause: e
1684
1790
  end
1685
1791
 
1686
1792
  out.each_line do |line|
@@ -1698,13 +1804,13 @@ body > svg {
1698
1804
  end
1699
1805
  end
1700
1806
 
1701
- def get_epubcheck_command
1807
+ def build_epubcheck_command
1702
1808
  unless @epubcheck_path.nil?
1703
1809
  logger.debug %(Using ebook-epubcheck-path attribute: #{@epubcheck_path})
1704
1810
  return [@epubcheck_path]
1705
1811
  end
1706
1812
 
1707
- unless (result = ENV['EPUBCHECK']).nil?
1813
+ unless (result = ENV.fetch('EPUBCHECK', nil)).nil?
1708
1814
  logger.debug %(Using EPUBCHECK env variable: #{result})
1709
1815
  return [result]
1710
1816
  end
@@ -1719,12 +1825,13 @@ body > svg {
1719
1825
  end
1720
1826
  end
1721
1827
 
1722
- def validate_epub epub_file
1723
- argv = get_epubcheck_command + ['-w', epub_file]
1828
+ def validate_epub(epub_file)
1829
+ argv = build_epubcheck_command + ['-w', epub_file]
1724
1830
  begin
1725
1831
  out, err, res = Open3.capture3(*argv)
1726
1832
  rescue Errno::ENOENT => e
1727
- raise 'Unable to run EPUBCheck. Either install epubcheck-ruby gem or place `epubcheck` executable on PATH or set EPUBCHECK environment variable with path to it', cause: e
1833
+ raise 'Unable to run EPUBCheck. Either install epubcheck-ruby gem or place `epubcheck` executable on PATH or set EPUBCHECK environment variable with path to it',
1834
+ cause: e
1728
1835
  end
1729
1836
 
1730
1837
  out.each_line do |line|
@@ -1737,14 +1844,15 @@ body > svg {
1737
1844
  logger.error %(EPUB validation failed: #{epub_file}) unless res.success?
1738
1845
  end
1739
1846
 
1740
- def log_line line
1847
+ def log_line(line)
1741
1848
  line = line.strip
1742
1849
 
1743
- if line =~ /^fatal/i
1850
+ case line
1851
+ when /^fatal/i
1744
1852
  logger.fatal line
1745
- elsif line =~ /^error/i
1853
+ when /^error/i
1746
1854
  logger.error line
1747
- elsif line =~ /^warning/i
1855
+ when /^warning/i
1748
1856
  logger.warn line
1749
1857
  else
1750
1858
  logger.info line
@@ -1753,7 +1861,7 @@ body > svg {
1753
1861
 
1754
1862
  private
1755
1863
 
1756
- def class_string node
1864
+ def class_string(node)
1757
1865
  role = node.role
1758
1866
 
1759
1867
  return '' unless role_valid_class? role
@@ -1762,66 +1870,28 @@ body > svg {
1762
1870
  end
1763
1871
 
1764
1872
  # Handles asciidoctor 1.5.6 quirk when role can be parent
1765
- def role_valid_class? role
1873
+ def role_valid_class?(role)
1766
1874
  role.is_a? String
1767
1875
  end
1768
1876
  end
1769
1877
 
1770
- class DocumentIdGenerator
1771
- ReservedIds = %w(cover nav ncx)
1772
- CharRefRx = /&(?:([a-zA-Z][a-zA-Z]+\d{0,2})|#(\d\d\d{0,4})|#x([\da-fA-F][\da-fA-F][\da-fA-F]{0,3}));/
1773
- if defined? __dir__
1774
- InvalidIdCharsRx = /[^\p{Word}]+/
1775
- LeadingDigitRx = /^\p{Nd}/
1776
- else
1777
- InvalidIdCharsRx = /[^[:word:]]+/
1778
- LeadingDigitRx = /^[[:digit:]]/
1779
- end
1780
-
1781
- class << self
1782
- def generate_id doc, pre = nil, sep = nil
1783
- synthetic = false
1784
- unless (id = doc.id)
1785
- # NOTE we assume pre is a valid ID prefix and that pre and sep only contain valid ID chars
1786
- pre ||= '_'
1787
- sep = sep ? sep.chr : '_'
1788
- if doc.header?
1789
- id = doc.doctitle sanitize: true
1790
- id = id.gsub CharRefRx do
1791
- $1 ? ($1 == 'amp' ? 'and' : sep) : ((d = $2 ? $2.to_i : $3.hex) == 8217 ? '' : ([d].pack 'U*'))
1792
- end if id.include? '&'
1793
- id = id.downcase.gsub InvalidIdCharsRx, sep
1794
- if id.empty?
1795
- id, synthetic = nil, true
1796
- else
1797
- unless sep.empty?
1798
- if (id = id.tr_s sep, sep).end_with? sep
1799
- if id == sep
1800
- id, synthetic = nil, true
1801
- else
1802
- id = (id.start_with? sep) ? id[1..-2] : id.chop
1803
- end
1804
- elsif id.start_with? sep
1805
- id = id[1..-1]
1806
- end
1807
- end
1808
- unless synthetic
1809
- if pre.empty?
1810
- id = %(_#{id}) if LeadingDigitRx =~ id
1811
- elsif !(id.start_with? pre)
1812
- id = %(#{pre}#{id})
1813
- end
1814
- end
1815
- end
1816
- elsif (first_section = doc.first_section)
1817
- id = first_section.id
1818
- else
1819
- synthetic = true
1820
- end
1821
- id = %(#{pre}document#{sep}#{doc.object_id}) if synthetic
1822
- end
1823
- logger.error %(chapter uses a reserved ID: #{id}) if !synthetic && (ReservedIds.include? id)
1824
- id
1878
+ class NumericIdGenerator
1879
+ def initialize
1880
+ @counter = 1
1881
+ end
1882
+
1883
+ # @param node [Asciidoctor::AbstractNode]
1884
+ # @return [void]
1885
+ def generate_id(node)
1886
+ if node.chapter? || node.is_a?(Asciidoctor::Document)
1887
+ node.id = %(_generated_id_#{@counter})
1888
+ @counter += 1
1889
+ end
1890
+
1891
+ # Recurse
1892
+ node.blocks.each do |subnode|
1893
+ # dlist contains array of *arrays* of blocks, so just skip them
1894
+ generate_id subnode if subnode.is_a?(Asciidoctor::AbstractBlock)
1825
1895
  end
1826
1896
  end
1827
1897
  end
@@ -1834,27 +1904,33 @@ body > svg {
1834
1904
  document.set_attribute 'pygments-style', 'bw' unless document.attr? 'pygments-style'
1835
1905
  document.set_attribute 'rouge-style', 'bw' unless document.attr? 'rouge-style'
1836
1906
 
1837
- # Old asciidoctor versions do not have public API for writing highlighter CSS file
1838
- # So just use inline CSS there.
1839
- unless Document.supports_syntax_highlighter?
1840
- document.set_attribute 'coderay-css', 'style'
1841
- document.set_attribute 'pygments-css', 'style'
1842
- document.set_attribute 'rouge-css', 'style'
1843
- end
1844
-
1845
1907
  case (ebook_format = document.attributes['ebook-format'])
1846
1908
  when 'epub3', 'kf8'
1847
1909
  # all good
1848
1910
  when 'mobi'
1849
1911
  ebook_format = document.attributes['ebook-format'] = 'kf8'
1850
1912
  else
1851
- # QUESTION should we display a warning?
1913
+ # QUESTION: should we display a warning?
1852
1914
  ebook_format = document.attributes['ebook-format'] = 'epub3'
1853
1915
  end
1854
1916
  document.attributes[%(ebook-format-#{ebook_format})] = ''
1917
+
1918
+ # Enable generation of section ids because we use them for chapter filenames
1919
+ document.set_attribute 'sectids'
1855
1920
  treeprocessor do
1856
1921
  process do |doc|
1857
- doc.id = DocumentIdGenerator.generate_id doc, (doc.attr 'idprefix'), (doc.attr 'idseparator')
1922
+ if ebook_format == 'kf8'
1923
+ # Kindlegen doesn't support unicode ids
1924
+ NumericIdGenerator.new.generate_id doc
1925
+ else
1926
+ # :sectids: doesn't generate id for top-level section (why?), do it manually
1927
+ doc.id = Section.generate_id(doc.first_section&.title || doc.attr('docname') || 'document', doc) if doc.id.nil_or_empty?
1928
+
1929
+ if (preamble = doc.blocks[0]) && preamble.context == :preamble && preamble.id.nil_or_empty?
1930
+ # :sectids: doesn't generate id for preamble (because it is not a section), do it manually
1931
+ preamble.id = Section.generate_id(preamble.title || 'preamble', doc)
1932
+ end
1933
+ end
1858
1934
  nil
1859
1935
  end
1860
1936
  end