asciidoctor-epub3 1.5.0.alpha.13 → 1.5.0.alpha.18

Sign up to get free protection for your applications and to get access to all the features.
@@ -5,7 +5,6 @@ require 'asciidoctor/extensions'
5
5
  require 'gepub'
6
6
  require_relative 'asciidoctor-epub3/ext'
7
7
  require_relative 'asciidoctor-epub3/converter'
8
- require_relative 'asciidoctor-epub3/packager'
9
8
 
10
9
  # We need to be able to write files with unicode names. See https://github.com/asciidoctor/asciidoctor-epub3/issues/217
11
10
  ::Zip.unicode_names = true
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'spine_item_processor'
3
+ require 'mime/types'
4
+ require 'open3'
4
5
  require_relative 'font_icon_map'
5
6
 
6
7
  module Asciidoctor
@@ -14,56 +15,45 @@ module Asciidoctor
14
15
 
15
16
  register_for 'epub3'
16
17
 
17
- def initialize backend, opts
18
- super
19
- basebackend 'html'
20
- outfilesuffix '.epub' # dummy outfilesuffix since it may be .mobi
21
- htmlsyntax 'xml'
22
- @validate = false
23
- @extract = false
24
- @kindlegen_path = nil
25
- @epubcheck_path = nil
26
- end
27
-
28
- def convert node, name = nil
29
- if (name ||= node.node_name) == 'document'
30
- @validate = node.attr? 'ebook-validate'
31
- @extract = node.attr? 'ebook-extract'
32
- @compress = node.attr 'ebook-compress'
33
- @kindlegen_path = node.attr 'ebook-kindlegen-path'
34
- @epubcheck_path = node.attr 'ebook-epubcheck-path'
35
- spine_items = node.references[:spine_items]
36
- if spine_items.nil?
37
- logger.error %(#{::File.basename node.document.attr('docfile')}: failed to find spine items, produced file will be invalid)
38
- spine_items = []
18
+ def write output, target
19
+ epub_file = @format == :kf8 ? %(#{::Asciidoctor::Helpers.rootname target}-kf8.epub) : target
20
+ output.generate_epub epub_file
21
+ logger.debug %(Wrote #{@format.upcase} to #{epub_file})
22
+ if @extract
23
+ extract_dir = epub_file.sub EpubExtensionRx, ''
24
+ ::FileUtils.remove_dir extract_dir if ::File.directory? extract_dir
25
+ ::Dir.mkdir extract_dir
26
+ ::Dir.chdir extract_dir do
27
+ ::Zip::File.open epub_file do |entries|
28
+ entries.each do |entry|
29
+ next unless entry.file?
30
+ unless (entry_dir = ::File.dirname entry.name) == '.' || (::File.directory? entry_dir)
31
+ ::FileUtils.mkdir_p entry_dir
32
+ end
33
+ entry.extract entry.name
34
+ end
35
+ end
39
36
  end
40
- Packager.new node, spine_items, node.attributes['ebook-format'].to_sym
41
- # converting an element from the spine document, such as an inline node in the doctitle
42
- elsif name.start_with? 'inline_'
43
- (@content_converter ||= ::Asciidoctor::Converter::Factory.default.create 'epub3-xhtml5').convert node, name
44
- else
45
- raise ::ArgumentError, %(Encountered unexpected node in epub3 package converter: #{name})
37
+ logger.debug %(Extracted #{@format.upcase} to #{extract_dir})
46
38
  end
47
- end
48
39
 
49
- # FIXME: we have to package in write because we don't have access to target before this point
50
- def write packager, target
51
- packager.package validate: @validate, extract: @extract, compress: @compress, kindlegen_path: @kindlegen_path, epubcheck_path: @epubcheck_path, target: target
52
- nil
40
+ if @format == :kf8
41
+ # QUESTION shouldn't we validate this epub file too?
42
+ distill_epub_to_mobi epub_file, target, @compress, @kindlegen_path
43
+ elsif @validate
44
+ validate_epub epub_file, @epubcheck_path
45
+ end
53
46
  end
54
- end
55
47
 
56
- # Public: The converter for the epub3 backend that converts the individual
57
- # content documents in an EPUB3 publication.
58
- class ContentConverter
59
- include ::Asciidoctor::Converter
60
- include ::Asciidoctor::Logging
48
+ CsvDelimiterRx = /\s*,\s*/
61
49
 
62
- register_for 'epub3-xhtml5'
50
+ DATA_DIR = ::File.expand_path ::File.join(__dir__, '..', '..', 'data')
51
+ ImageMacroRx = /^image::?(.*?)\[(.*?)\]$/
52
+ ImgSrcScanRx = /<img src="(.+?)"/
53
+ SvgImgSniffRx = /<img src=".+?\.svg"/
63
54
 
64
55
  LF = ?\n
65
56
  NoBreakSpace = '&#xa0;'
66
- ThinNoBreakSpace = '&#x202f;'
67
57
  RightAngleQuote = '&#x203a;'
68
58
  CalloutStartNum = %(\u2460)
69
59
 
@@ -87,13 +77,27 @@ module Asciidoctor
87
77
 
88
78
  ToHtmlSpecialCharsRx = /[#{ToHtmlSpecialCharsMap.keys.join}]/
89
79
 
90
- def initialize backend, opts
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 = {}
91
97
  super
92
98
  basebackend 'html'
93
- outfilesuffix '.xhtml'
99
+ outfilesuffix '.epub' # dummy outfilesuffix since it may be .mobi
94
100
  htmlsyntax 'xml'
95
- @xrefs_seen = ::Set.new
96
- @icon_names = []
97
101
  end
98
102
 
99
103
  def convert node, name = nil, _opts = {}
@@ -101,37 +105,257 @@ module Asciidoctor
101
105
  if respond_to? method_name
102
106
  send method_name, node
103
107
  else
104
- logger.warn %(conversion missing in backend #{@backend} for #{name})
108
+ logger.warn %(#{::File.basename node.attr('docfile')}: conversion missing in backend #{@backend} for #{name})
109
+ nil
110
+ end
111
+ end
112
+
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
121
+ end
122
+
123
+ def get_numbered_title node
124
+ doc_attrs = node.document.attributes
125
+ level = node.level
126
+ if node.caption
127
+ title = node.captioned_title
128
+ elsif node.respond_to?(:numbered) && node.numbered && level <= (doc_attrs['sectnumlevels'] || 3).to_i
129
+ 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})
134
+ else
135
+ title = %(#{node.sectnum} #{node.title})
136
+ end
137
+ else
138
+ title = %(#{node.sectnum} #{node.title})
139
+ end
140
+ else
141
+ title = node.title
105
142
  end
143
+ title
106
144
  end
107
145
 
108
146
  def convert_document node
109
- docid = node.id
110
- pubtype = node.attr 'publication-type', 'book'
147
+ @format = node.attr('ebook-format').to_sym
148
+
149
+ @validate = node.attr? 'ebook-validate'
150
+ @extract = node.attr? 'ebook-extract'
151
+ @compress = node.attr 'ebook-compress'
152
+ @kindlegen_path = node.attr 'ebook-kindlegen-path'
153
+ @epubcheck_path = node.attr 'ebook-epubcheck-path'
154
+ @xrefs_seen = ::Set.new
155
+ @icon_names = []
156
+ @media_files = []
157
+ @footnotes = []
158
+
159
+ @book = GEPUB::Book.new 'EPUB/package.opf'
160
+ @book.epub_backward_compat = @format != :kf8
161
+ @book.language node.attr('lang', 'en'), id: 'pub-language'
162
+
163
+ if node.attr? 'uuid'
164
+ @book.primary_identifier node.attr('uuid'), 'pub-identifier', 'uuid'
165
+ else
166
+ @book.primary_identifier node.id, 'pub-identifier', 'uuid'
167
+ end
168
+ # replace with next line once the attributes argument is supported
169
+ #unique_identifier doc.id, 'pub-id', 'uuid', 'scheme' => 'xsd:string'
170
+
171
+ # NOTE we must use :plain_text here since gepub reencodes
172
+ @book.add_title sanitize_doctitle_xml(node, :plain_text), id: 'pub-title'
173
+
174
+ # see https://www.w3.org/publishing/epub3/epub-packages.html#sec-opf-dccreator
175
+ (1..(node.attr 'authorcount', 1).to_i).map do |idx|
176
+ author = node.attr(idx == 1 ? 'author' : %(author_#{idx}))
177
+ @book.add_creator author, role: 'aut' unless author.nil_or_empty?
178
+ end
179
+
180
+ publisher = node.attr 'publisher'
181
+ # NOTE Use producer as both publisher and producer if publisher isn't specified
182
+ publisher = node.attr 'producer' if publisher.nil_or_empty?
183
+ @book.publisher = publisher unless publisher.nil_or_empty?
184
+
185
+ if node.attr? 'reproducible'
186
+ # We need to set lastmodified to some fixed value. Otherwise, gepub will set it to current date.
187
+ @book.lastmodified = (::Time.at 0).utc
188
+ # Is it correct that we do not populate dc:date when 'reproducible' is set?
189
+ else
190
+ if node.attr? 'revdate'
191
+ begin
192
+ @book.date = node.attr 'revdate'
193
+ rescue ArgumentError => e
194
+ logger.error %(#{::File.basename node.attr('docfile')}: failed to parse revdate: #{e})
195
+ @book.date = node.attr 'docdatetime'
196
+ end
197
+ else
198
+ @book.date = node.attr 'docdatetime'
199
+ end
200
+ @book.lastmodified = node.attr 'localdatetime'
201
+ end
202
+
203
+ @book.description = node.attr 'description' if node.attr? 'description'
204
+ @book.source = node.attr 'source' if node.attr? 'source'
205
+ @book.rights = node.attr 'copyright' if node.attr? 'copyright'
206
+
207
+ (node.attr 'keywords', '').split(CsvDelimiterRx).each do |s|
208
+ @book.metadata.add_metadata 'subject', s
209
+ end
210
+
211
+ if node.attr? 'series-name'
212
+ series_name = node.attr 'series-name'
213
+ series_volume = node.attr 'series-volume', 1
214
+ series_id = node.attr 'series-id'
111
215
 
112
- if (doctitle = node.doctitle partition: true, use_fallback: true).subtitle?
216
+ series_meta = @book.metadata.add_metadata 'meta', series_name, id: 'pub-collection', group_position: series_volume
217
+ series_meta['property'] = 'belongs-to-collection'
218
+ series_meta.refine 'dcterms:identifier', series_id unless series_id.nil?
219
+ # Calibre only understands 'series'
220
+ series_meta.refine 'collection-type', 'series'
221
+ end
222
+
223
+ # For list of supported landmark types see
224
+ # https://idpf.github.io/epub-vocabs/structure/
225
+ landmarks = []
226
+
227
+ cover_page = add_cover_page node
228
+ landmarks << { type: 'cover', href: cover_page.href, title: 'Cover' } unless cover_page.nil?
229
+
230
+ front_matter_page = add_front_matter_page node
231
+ landmarks << { type: 'frontmatter', href: front_matter_page.href, title: 'Front Matter' } unless front_matter_page.nil?
232
+
233
+ nav_item = @book.add_item('nav.xhtml', id: 'nav').nav
234
+
235
+ toclevels = [(node.attr 'toclevels', 1).to_i, 0].max
236
+ outlinelevels = [(node.attr 'outlinelevels', toclevels).to_i, 0].max
237
+
238
+ if node.attr? 'toc'
239
+ toc_item = @book.add_ordered_item 'toc.xhtml', id: 'toc'
240
+ landmarks << { type: 'toc', href: toc_item.href, title: node.attr('toc-title') }
241
+ else
242
+ toc_item = nil
243
+ end
244
+
245
+ if node.doctype == 'book'
246
+ toc_items = node.sections
247
+ node.content
248
+ else
249
+ toc_items = [node]
250
+ add_chapter node
251
+ end
252
+
253
+ landmarks << { type: 'bodymatter', href: %(#{get_chapter_name toc_items[0]}.xhtml), title: 'Start of Content' } unless toc_items.empty?
254
+
255
+ toc_items.each do |item|
256
+ landmarks << { type: item.style, href: %(#{get_chapter_name item}.xhtml), title: item.title } if %w(appendix bibliography glossary index preface).include? item.style
257
+ end
258
+
259
+ nav_item.add_content postprocess_xhtml(nav_doc(node, toc_items, landmarks, outlinelevels))
260
+ # User is not supposed to see landmarks, so pass empty array here
261
+ toc_item&.add_content postprocess_xhtml(nav_doc(node, toc_items, [], toclevels))
262
+
263
+ # NOTE gepub doesn't support building a ncx TOC with depth > 1, so do it ourselves
264
+ toc_ncx = ncx_doc node, toc_items, outlinelevels
265
+ @book.add_item 'toc.ncx', content: toc_ncx.to_ios, id: 'ncx'
266
+
267
+ docimagesdir = (node.attr 'imagesdir', '.').chomp '/'
268
+ docimagesdir = (docimagesdir == '.' ? nil : %(#{docimagesdir}/))
269
+
270
+ @media_files.each do |file|
271
+ if file[:name].start_with? %(#{docimagesdir}jacket/cover.)
272
+ logger.warn %(path is reserved for cover artwork: #{file[:name]}; skipping file found in content)
273
+ elsif file[:path].nil? || File.readable?(file[:path])
274
+ mime_types = MIME::Types.type_for file[:name]
275
+ mime_types.delete_if {|x| x.media_type != file[:media_type] }
276
+ preferred_mime_type = mime_types.empty? ? nil : mime_types[0].content_type
277
+ @book.add_item file[:name], content: file[:path], media_type: preferred_mime_type
278
+ else
279
+ logger.error %(#{File.basename node.attr('docfile')}: media file not found or not readable: #{file[:path]})
280
+ end
281
+ end
282
+
283
+ #add_metadata 'ibooks:specified-fonts', true
284
+
285
+ add_theme_assets node
286
+ if node.doctype != 'book'
287
+ usernames = [node].map {|item| item.attr 'username' }.compact.uniq
288
+ add_profile_images node, usernames
289
+ end
290
+
291
+ @book
292
+ end
293
+
294
+ # FIXME: move to Asciidoctor::Helpers
295
+ def sanitize_doctitle_xml doc, content_spec
296
+ doctitle = doc.doctitle use_fallback: true
297
+ sanitize_xml doctitle, content_spec
298
+ end
299
+
300
+ # FIXME: move to Asciidoctor::Helpers
301
+ def sanitize_xml content, content_spec
302
+ if content_spec != :pcdata && (content.include? '<')
303
+ if (content = (content.gsub XmlElementRx, '').strip).include? ' '
304
+ content = content.tr_s ' ', ' '
305
+ end
306
+ end
307
+
308
+ case content_spec
309
+ when :attribute_cdata
310
+ content = content.gsub '"', '&quot;' if content.include? '"'
311
+ when :cdata, :pcdata
312
+ # noop
313
+ when :plain_text
314
+ if content.include? ';'
315
+ content = content.gsub(CharEntityRx) { [$1.to_i].pack 'U*' } if content.include? '&#'
316
+ content = content.gsub FromHtmlSpecialCharsRx, FromHtmlSpecialCharsMap
317
+ end
318
+ else
319
+ raise ::ArgumentError, %(Unknown content spec: #{content_spec})
320
+ end
321
+ content
322
+ end
323
+
324
+ def add_chapter node
325
+ docid = get_chapter_name node
326
+ return nil if docid.nil?
327
+
328
+ chapter_item = @book.add_ordered_item %(#{docid}.xhtml)
329
+
330
+ doctitle = node.document.doctitle partition: true, use_fallback: true
331
+ doctitle_sanitized = sanitize_xml doctitle.combined, :cdata
332
+
333
+ if node.context == :document && doctitle.subtitle?
113
334
  title = %(#{doctitle.main} )
114
335
  subtitle = doctitle.subtitle
115
- else
336
+ elsif node.title
116
337
  # HACK: until we get proper handling of title-only in CSS
117
338
  title = ''
118
- subtitle = doctitle.combined
339
+ subtitle = get_numbered_title node
340
+ doctitle_sanitized = sanitize_xml subtitle, :cdata
341
+ else
342
+ title = nil
343
+ subtitle = nil
119
344
  end
120
345
 
121
- doctitle_sanitized = (node.doctitle sanitize: true, use_fallback: true).to_s
122
- subtitle_formatted = subtitle.split.map {|w| %(<b>#{w}</b>) } * ' '
123
-
124
- if pubtype == 'book'
346
+ if node.document.doctype == 'book'
125
347
  byline = ''
126
348
  else
127
349
  author = node.attr 'author'
128
350
  username = node.attr 'username', 'default'
129
- imagesdir = (node.references[:spine].attr 'imagesdir', '.').chomp '/'
351
+ imagesdir = (node.document.attr 'imagesdir', '.').chomp '/'
130
352
  imagesdir = imagesdir == '.' ? '' : %(#{imagesdir}/)
131
353
  byline = %(<p class="byline"><img src="#{imagesdir}avatars/#{username}.jpg"/> <b class="author">#{author}</b></p>#{LF})
132
354
  end
133
355
 
134
- mark_last_paragraph node unless pubtype == 'book'
356
+ mark_last_paragraph node unless node.document.doctype == 'book'
357
+
358
+ @xrefs_seen.clear
135
359
  content = node.content
136
360
 
137
361
  # NOTE must run after content is resolved
@@ -140,7 +364,7 @@ module Asciidoctor
140
364
  icon_css_head = ''
141
365
  else
142
366
  icon_defs = @icon_names.map {|name|
143
- %(.i-#{name}::before { content: "#{FontIconMap[name.tr('-', '_').to_sym]}"; })
367
+ %(.i-#{name}::before { content: "#{FontIconMap.unicode name}"; })
144
368
  } * LF
145
369
  icon_css_head = %(<style>
146
370
  #{icon_defs}
@@ -148,9 +372,19 @@ module Asciidoctor
148
372
  )
149
373
  end
150
374
 
375
+ header = (title || subtitle) ? %(<header>
376
+ <div class="chapter-header">
377
+ #{byline}<h1 class="chapter-title">#{title}#{subtitle ? %(<small class="subtitle">#{subtitle}</small>) : ''}</h1>
378
+ </div>
379
+ </header>) : ''
380
+
381
+ # We want highlighter CSS to be stored in a separate file
382
+ # in order to avoid style duplication across chapter files
383
+ linkcss = true
384
+
151
385
  # NOTE kindlegen seems to mangle the <header> element, so we wrap its content in a div
152
386
  lines = [%(<!DOCTYPE html>
153
- <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="#{lang = node.attr 'lang', 'en'}" lang="#{lang}">
387
+ <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}">
154
388
  <head>
155
389
  <meta charset="UTF-8"/>
156
390
  <title>#{doctitle_sanitized}</title>
@@ -164,23 +398,25 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
164
398
  }
165
399
  document.body.setAttribute('class', reader.name.toLowerCase().replace(/ /g, '-'));
166
400
  });
167
- ]]></script>
168
- </head>
401
+ ]]></script>)]
402
+
403
+ syntax_hl = node.document.syntax_highlighter
404
+ lines << (syntax_hl.docinfo :head, node, linkcss: linkcss, self_closing_tag_slash: '/') if syntax_hl&.docinfo? :head
405
+
406
+ lines << %(</head>
169
407
  <body>
170
- <section class="chapter" title="#{doctitle_sanitized.gsub '"', '&quot;'}" epub:type="chapter" id="#{docid}">
171
- <header>
172
- <div class="chapter-header">
173
- #{byline}<h1 class="chapter-title">#{title}#{subtitle ? %(<small class="subtitle">#{subtitle_formatted}</small>) : ''}</h1>
174
- </div>
175
- </header>
176
- #{content})]
408
+ <section class="chapter" title="#{doctitle_sanitized}" epub:type="chapter" id="#{docid}">
409
+ #{header}
410
+ #{content})
411
+
412
+ unless (fns = node.document.footnotes - @footnotes).empty?
413
+ @footnotes += fns
177
414
 
178
- if node.footnotes?
179
415
  # NOTE kindlegen seems to mangle the <footer> element, so we wrap its content in a div
180
416
  lines << '<footer>
181
417
  <div class="chapter-footer">
182
418
  <div class="footnotes">'
183
- node.footnotes.each do |footnote|
419
+ fns.each do |footnote|
184
420
  lines << %(<aside id="note-#{footnote.index}" epub:type="footnote">
185
421
  <p><sup class="noteref"><a href="#noteref-#{footnote.index}">#{footnote.index}</a></sup> #{footnote.text}</p>
186
422
  </aside>)
@@ -190,44 +426,53 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
190
426
  </footer>'
191
427
  end
192
428
 
193
- lines << '</section>
194
- </body>
429
+ lines << '</section>'
430
+
431
+ lines << (syntax_hl.docinfo :footer, node.document, linkcss: linkcss, self_closing_tag_slash: '/') if syntax_hl&.docinfo? :footer
432
+
433
+ lines << '</body>
195
434
  </html>'
196
435
 
197
- lines * LF
198
- end
436
+ chapter_item.add_content postprocess_xhtml lines * LF
437
+ epub_properties = node.attr 'epub-properties'
438
+ chapter_item.add_property 'svg' if epub_properties&.include? 'svg'
199
439
 
200
- # NOTE embedded is used for AsciiDoc table cell content
201
- def convert_embedded node
202
- node.content
440
+ # # QUESTION reenable?
441
+ # #linear 'yes' if i == 0
442
+
443
+ chapter_item
203
444
  end
204
445
 
205
446
  def convert_section node
206
- hlevel = node.level + 1
207
- epub_type_attr = node.special ? %( epub:type="#{node.sectname}") : ''
208
- div_classes = [%(sect#{node.level}), node.role].compact
209
- title = node.title
210
- title_sanitized = xml_sanitize title
211
- if node.document.header? || node.level != 1 || node != node.document.first_section
447
+ if add_chapter(node).nil?
448
+ hlevel = node.level
449
+ epub_type_attr = node.special ? %( epub:type="#{node.sectname}") : ''
450
+ div_classes = [%(sect#{node.level}), node.role].compact
451
+ title = get_numbered_title node
452
+ title_sanitized = xml_sanitize title
212
453
  %(<section class="#{div_classes * ' '}" title="#{title_sanitized}"#{epub_type_attr}>
213
454
  <h#{hlevel} id="#{node.id}">#{title}</h#{hlevel}>#{(content = node.content).empty? ? '' : %(
214
455
  #{content})}
215
456
  </section>)
216
- else
217
- # document has no level-0 heading and this heading serves as the document title
218
- node.content
219
457
  end
220
458
  end
221
459
 
460
+ # NOTE embedded is used for AsciiDoc table cell content
461
+ def convert_embedded node
462
+ node.content
463
+ end
464
+
222
465
  # TODO: support use of quote block as abstract
223
466
  def convert_preamble node
224
- if (first_block = node.blocks[0]) && first_block.style == 'abstract'
225
- convert_abstract first_block
226
- # REVIEW: should we treat the preamble as an abstract in general?
227
- elsif first_block && node.blocks.size == 1
228
- convert_abstract first_block
229
- else
230
- node.content
467
+ if add_chapter(node).nil?
468
+ if (first_block = node.blocks[0]) && first_block.style == 'abstract'
469
+ convert_abstract first_block
470
+ # REVIEW: should we treat the preamble as an abstract in general?
471
+ elsif first_block && node.blocks.size == 1
472
+ convert_abstract first_block
473
+ else
474
+ node.content
475
+ end
231
476
  end
232
477
  end
233
478
 
@@ -250,15 +495,16 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
250
495
  end
251
496
 
252
497
  def convert_paragraph node
498
+ id_attr = node.id ? %( id="#{node.id}") : ''
253
499
  role = node.role
254
500
  # stack-head is the alternative to the default, inline-head (where inline means "run-in")
255
501
  head_stop = node.attr 'head-stop', (role && (node.has_role? 'stack-head') ? nil : '.')
256
502
  head = node.title? ? %(<strong class="head">#{title = node.title}#{head_stop && title !~ TrailingPunctRx ? head_stop : ''}</strong> ) : ''
257
503
  if role
258
504
  node.set_option 'hardbreaks' if node.has_role? 'signature'
259
- %(<p class="#{role}">#{head}#{node.content}</p>)
505
+ %(<p#{id_attr} class="#{role}">#{head}#{node.content}</p>)
260
506
  else
261
- %(<p>#{head}#{node.content}</p>)
507
+ %(<p#{id_attr}>#{head}#{node.content}</p>)
262
508
  end
263
509
  end
264
510
 
@@ -292,6 +538,9 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
292
538
  'note'
293
539
  when 'important', 'warning', 'caution'
294
540
  'warning'
541
+ else
542
+ logger.warn %(unknown admonition type: #{type})
543
+ 'note'
295
544
  end
296
545
  %(<aside#{id_attr} class="admonition #{type}"#{title_attr} epub:type="#{epub_type}">
297
546
  #{title_el}<div class="content">
@@ -318,18 +567,56 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
318
567
  end
319
568
 
320
569
  def convert_listing node
570
+ nowrap = (node.option? 'nowrap') || !(node.document.attr? 'prewrap')
571
+ if node.style == 'source'
572
+ lang = node.attr 'language'
573
+ syntax_hl = node.document.syntax_highlighter
574
+ if syntax_hl
575
+ opts = syntax_hl.highlight? ? {
576
+ css_mode: ((doc_attrs = node.document.attributes)[%(#{syntax_hl.name}-css)] || :class).to_sym,
577
+ style: doc_attrs[%(#{syntax_hl.name}-style)],
578
+ } : {}
579
+ opts[:nowrap] = nowrap
580
+ else
581
+ pre_open = %(<pre class="highlight#{nowrap ? ' nowrap' : ''}"><code#{lang ? %( class="language-#{lang}" data-lang="#{lang}") : ''}>)
582
+ pre_close = '</code></pre>'
583
+ end
584
+ else
585
+ pre_open = %(<pre#{nowrap ? ' class="nowrap"' : ''}>)
586
+ pre_close = '</pre>'
587
+ syntax_hl = nil
588
+ end
321
589
  figure_classes = ['listing']
322
590
  figure_classes << 'coalesce' if node.option? 'unbreakable'
323
- pre_classes = node.style == 'source' ? ['source', %(language-#{node.attr 'language'})] : ['screen']
324
- title_div = node.title? ? %(<figcaption>#{node.captioned_title}</figcaption>
325
- ) : ''
326
- # patches conums to fix extra or missing leading space
327
- # TODO remove patch once upgrading to Asciidoctor 1.5.6
328
- %(<figure class="#{figure_classes * ' '}">
329
- #{title_div}<pre class="#{pre_classes * ' '}"><code>#{(node.content || '').gsub(/(?<! )<i class="conum"| +<i class="conum"/, ' <i class="conum"')}</code></pre>
591
+ title_div = node.title? ? %(<figcaption>#{node.captioned_title}</figcaption>) : ''
592
+ %(<figure class="#{figure_classes * ' '}">#{title_div}
593
+ #{syntax_hl ? (syntax_hl.format node, lang, opts) : pre_open + (node.content || '') + pre_close}
594
+ </figure>)
595
+ end
596
+
597
+ def convert_stem node
598
+ return convert_listing node if node.style != 'asciimath' || !asciimath_available?
599
+
600
+ id_attr = node.id ? %( id="#{node.id}") : ''
601
+ title_element = node.title? ? %(<figcaption>#{node.captioned_title}</figcaption>) : ''
602
+ equation_data = AsciiMath.parse(node.content).to_mathml 'mml:'
603
+
604
+ %(<figure#{id_attr} class="#{prepend_space node.role}">
605
+ #{title_element}
606
+ <div class="content">
607
+ #{equation_data}
608
+ </div>
330
609
  </figure>)
331
610
  end
332
611
 
612
+ def asciimath_available?
613
+ (@asciimath_status ||= load_asciimath) == :loaded
614
+ end
615
+
616
+ def load_asciimath
617
+ Helpers.require_library('asciimath', true, :warn).nil? ? :unavailable : :loaded
618
+ end
619
+
333
620
  # QUESTION should we wrap the <pre> in either <div> or <figure>?
334
621
  def convert_literal node
335
622
  %(<pre class="screen">#{node.content}</pre>)
@@ -344,7 +631,7 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
344
631
  end
345
632
 
346
633
  def convert_quote node
347
- id_attr = %( id="#{node.id}") if node.id
634
+ id_attr = node.id ? %( id="#{node.id}") : ''
348
635
  class_attr = (role = node.role) ? %( class="blockquote #{role}") : ' class="blockquote"'
349
636
 
350
637
  footer_content = []
@@ -370,7 +657,7 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
370
657
  end
371
658
 
372
659
  def convert_verse node
373
- id_attr = %( id="#{node.id}") if node.id
660
+ id_attr = node.id ? %( id="#{node.id}") : ''
374
661
  class_attr = (role = node.role) ? %( class="verse #{role}") : ' class="verse"'
375
662
 
376
663
  footer_content = []
@@ -414,43 +701,38 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
414
701
  lines = [%(<div class="table">)]
415
702
  lines << %(<div class="content">)
416
703
  table_id_attr = node.id ? %( id="#{node.id}") : ''
417
- frame_class = {
418
- 'all' => 'table-framed',
419
- 'topbot' => 'table-framed-topbot',
420
- 'sides' => 'table-framed-sides',
421
- 'none' => '',
422
- }
423
- grid_class = {
424
- 'all' => 'table-grid',
425
- 'rows' => 'table-grid-rows',
426
- 'cols' => 'table-grid-cols',
427
- 'none' => '',
428
- }
429
- table_classes = %W[table #{frame_class[node.attr 'frame'] || frame_class['topbot']} #{grid_class[node.attr 'grid'] || grid_class['rows']}]
704
+ table_classes = [
705
+ 'table',
706
+ %(table-framed-#{node.attr 'frame', 'rows', 'table-frame'}),
707
+ %(table-grid-#{node.attr 'grid', 'rows', 'table-grid'}),
708
+ ]
430
709
  if (role = node.role)
431
710
  table_classes << role
432
711
  end
433
- table_class_attr = %( class="#{table_classes * ' '}")
434
712
  table_styles = []
435
- table_styles << %(width: #{node.attr 'tablepcwidth'}%) unless (node.option? 'autowidth') && !(node.attr? 'width', nil, false)
713
+ if (autowidth = node.option? 'autowidth') && !(node.attr? 'width')
714
+ table_classes << 'fit-content'
715
+ elsif (tablewidth = node.attr 'tablepcwidth') == 100
716
+ table_classes << 'stretch'
717
+ else
718
+ table_styles << %(width: #{tablewidth}%;)
719
+ end
720
+ table_class_attr = %( class="#{table_classes * ' '}")
436
721
  table_style_attr = !table_styles.empty? ? %( style="#{table_styles * '; '}") : ''
437
722
 
438
723
  lines << %(<table#{table_id_attr}#{table_class_attr}#{table_style_attr}>)
439
724
  lines << %(<caption>#{node.captioned_title}</caption>) if node.title?
440
725
  if (node.attr 'rowcount') > 0
441
726
  lines << '<colgroup>'
442
- #if node.option? 'autowidth'
443
- tag = %(<col/>)
444
- node.columns.size.times do
445
- lines << tag
446
- end
447
- #else
448
- # node.columns.each do |col|
449
- # lines << %(<col style="width: #{col.attr 'colpcwidth'}%"/>)
450
- # end
451
- #end
727
+ if autowidth
728
+ lines += (Array.new node.columns.size, %(<col/>))
729
+ else
730
+ node.columns.each do |col|
731
+ lines << ((col.option? 'autowidth') ? %(<col/>) : %(<col style="width: #{col.attr 'colpcwidth'}%;" />))
732
+ end
733
+ end
452
734
  lines << '</colgroup>'
453
- [:head, :foot, :body].reject {|tsec| node.rows[tsec].empty? }.each do |tsec|
735
+ [:head, :body, :foot].reject {|tsec| node.rows[tsec].empty? }.each do |tsec|
454
736
  lines << %(<t#{tsec}>)
455
737
  node.rows[tsec].each do |row|
456
738
  lines << '<tr>'
@@ -468,19 +750,16 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
468
750
  else
469
751
  cell_content = ''
470
752
  cell.content.each do |text|
471
- cell_content = %(#{cell_content}<p>#{text}</p>)
753
+ cell_content = %(#{cell_content}<p class="tableblock">#{text}</p>)
472
754
  end
473
755
  end
474
756
  end
475
757
 
476
758
  cell_tag_name = tsec == :head || cell.style == :header ? 'th' : 'td'
477
- cell_classes = []
478
- if (halign = cell.attr 'halign') && halign != 'left'
479
- cell_classes << 'halign-left'
480
- end
481
- if (halign = cell.attr 'valign') && halign != 'top'
482
- cell_classes << 'valign-top'
483
- end
759
+ cell_classes = [
760
+ "halign-#{cell.attr 'halign'}",
761
+ "valign-#{cell.attr 'valign'}",
762
+ ]
484
763
  cell_class_attr = !cell_classes.empty? ? %( class="#{cell_classes * ' '}") : ''
485
764
  cell_colspan_attr = cell.colspan ? %( colspan="#{cell.colspan}") : ''
486
765
  cell_rowspan_attr = cell.rowspan ? %( rowspan="#{cell.rowspan}") : ''
@@ -513,16 +792,30 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
513
792
  # TODO: add complex class if list has nested blocks
514
793
  def convert_dlist node
515
794
  lines = []
795
+ id_attribute = node.id ? %( id="#{node.id}") : ''
796
+
797
+ classes = case node.style
798
+ when 'horizontal'
799
+ ['hdlist', node.role]
800
+ when 'itemized', 'ordered'
801
+ # QUESTION should we just use itemized-list and ordered-list as the class here? or just list?
802
+ ['dlist', %(#{node.style}-list), node.role]
803
+ else
804
+ ['description-list']
805
+ end.compact
806
+
807
+ class_attribute = %( class="#{classes.join ' '}")
808
+
809
+ lines << %(<div#{id_attribute}#{class_attribute}>)
810
+ lines << %(<div class="title">#{node.title}</div>) if node.title?
811
+
516
812
  case (style = node.style)
517
813
  when 'itemized', 'ordered'
518
814
  list_tag_name = style == 'itemized' ? 'ul' : 'ol'
519
815
  role = node.role
520
816
  subject_stop = node.attr 'subject-stop', (role && (node.has_role? 'stack') ? nil : ':')
521
- # QUESTION should we just use itemized-list and ordered-list as the class here? or just list?
522
- div_classes = [%(#{style}-list), role].compact
523
817
  list_class_attr = (node.option? 'brief') ? ' class="brief"' : ''
524
- lines << %(<div class="#{div_classes * ' '}">
525
- <#{list_tag_name}#{list_class_attr}#{list_tag_name == 'ol' && (node.option? 'reversed') ? ' reversed="reversed"' : ''}>)
818
+ lines << %(<#{list_tag_name}#{list_class_attr}#{list_tag_name == 'ol' && (node.option? 'reversed') ? ' reversed="reversed"' : ''}>)
526
819
  node.items.each do |subjects, dd|
527
820
  # consists of one term (a subject) and supporting content
528
821
  subject = [*subjects].first.text
@@ -538,11 +831,40 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
538
831
  end
539
832
  lines << '</li>'
540
833
  end
541
- lines << %(</#{list_tag_name}>
542
- </div>)
834
+ lines << %(</#{list_tag_name}>)
835
+ when 'horizontal'
836
+ lines << '<table>'
837
+ if (node.attr? 'labelwidth') || (node.attr? 'itemwidth')
838
+ lines << '<colgroup>'
839
+ col_style_attribute = (node.attr? 'labelwidth') ? %( style="width: #{(node.attr 'labelwidth').chomp '%'}%;") : ''
840
+ lines << %(<col#{col_style_attribute} />)
841
+ col_style_attribute = (node.attr? 'itemwidth') ? %( style="width: #{(node.attr 'itemwidth').chomp '%'}%;") : ''
842
+ lines << %(<col#{col_style_attribute} />)
843
+ lines << '</colgroup>'
844
+ end
845
+ node.items.each do |terms, dd|
846
+ lines << '<tr>'
847
+ lines << %(<td class="hdlist1#{(node.option? 'strong') ? ' strong' : ''}">)
848
+ first_term = true
849
+ terms.each do |dt|
850
+ lines << %(<br />) unless first_term
851
+ lines << '<p>'
852
+ lines << dt.text
853
+ lines << '</p>'
854
+ first_term = nil
855
+ end
856
+ lines << '</td>'
857
+ lines << '<td class="hdlist2">'
858
+ if dd
859
+ lines << %(<p>#{dd.text}</p>) if dd.text?
860
+ lines << dd.content if dd.blocks?
861
+ end
862
+ lines << '</td>'
863
+ lines << '</tr>'
864
+ end
865
+ lines << '</table>'
543
866
  else
544
- lines << '<div class="description-list">
545
- <dl>'
867
+ lines << '<dl>'
546
868
  node.items.each do |terms, dd|
547
869
  [*terms].each do |dt|
548
870
  lines << %(<dt>
@@ -559,9 +881,10 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
559
881
  end
560
882
  lines << '</dd>'
561
883
  end
562
- lines << '</dl>
563
- </div>'
884
+ lines << '</dl>'
564
885
  end
886
+
887
+ lines << '</div>'
565
888
  lines * LF
566
889
  end
567
890
 
@@ -620,110 +943,200 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
620
943
  lines * LF
621
944
  end
622
945
 
623
- def convert_image node
624
- target = node.attr 'target'
625
- type = (::File.extname target)[1..-1]
626
- id_attr = node.id ? %( id="#{node.id}") : ''
627
- img_attrs = [%(alt="#{node.attr 'alt'}")]
628
- case type
629
- when 'svg'
630
- img_attrs << %(style="width: #{node.attr 'scaledwidth', '100%'}")
631
- # TODO: make this a convenience method on document
632
- epub_properties = (node.document.attributes['epub-properties'] ||= [])
946
+ def doc_option document, key
947
+ loop do
948
+ value = document.options[key]
949
+ return value unless value.nil?
950
+ document = document.parent_document
951
+ break if document.nil?
952
+ end
953
+ nil
954
+ end
955
+
956
+ def root_document document
957
+ document = document.parent_document until document.parent_document.nil?
958
+ document
959
+ end
960
+
961
+ def register_media_file node, target, media_type
962
+ if target.end_with?('.svg') || target.start_with?('data:image/svg+xml')
963
+ chapter = get_enclosing_chapter node
964
+ chapter.set_attr 'epub-properties', [] unless chapter.attr? 'epub-properties'
965
+ epub_properties = chapter.attr 'epub-properties'
633
966
  epub_properties << 'svg' unless epub_properties.include? 'svg'
967
+ end
968
+
969
+ if Asciidoctor::Helpers.uriish? target
970
+ # We need to add both local and remote media files to manifest
971
+ fs_path = nil
634
972
  else
635
- img_attrs << %(style="width: #{node.attr 'scaledwidth'}") if node.attr? 'scaledwidth'
636
- end
637
- =begin
638
- # NOTE to set actual width and height, use CSS width and height
639
- if type == 'svg'
640
- if node.attr? 'scaledwidth'
641
- img_attrs << %(width="#{node.attr 'scaledwidth'}")
642
- # Kindle
643
- #elsif node.attr? 'scaledheight'
644
- # img_attrs << %(width="#{node.attr 'scaledheight'}" height="#{node.attr 'scaledheight'}")
645
- # ePub3
646
- elsif node.attr? 'scaledheight'
647
- img_attrs << %(height="#{node.attr 'scaledheight'}" style="max-height: #{node.attr 'scaledheight'} !important")
648
- else
649
- # Aldiko doesn't not scale width to 100% by default
650
- img_attrs << %(width="100%")
973
+ out_dir = node.attr('outdir', nil, true) || doc_option(node.document, :to_dir)
974
+ fs_path = (::File.join out_dir, target)
975
+ unless ::File.exist? fs_path
976
+ base_dir = root_document(node.document).base_dir
977
+ fs_path = ::File.join base_dir, target
978
+ end
979
+ end
980
+ # We need *both* virtual and physical image paths. Unfortunately, references[:images] only has one of them.
981
+ @media_files << { name: target, path: fs_path, media_type: media_type }
651
982
  end
652
- end
653
- =end
983
+
984
+ def resolve_image_attrs node
985
+ img_attrs = []
986
+ img_attrs << %(alt="#{node.attr 'alt'}") if node.attr? 'alt'
987
+
988
+ width = node.attr 'scaledwidth'
989
+ width = node.attr 'width' if width.nil?
990
+
991
+ # Unlike browsers, Calibre/Kindle *do* scale image if only height is specified
992
+ # So, in order to match browser behavior, we just always omit height
993
+ img_attrs << %(width="#{width}") unless width.nil?
994
+
995
+ img_attrs
996
+ end
997
+
998
+ def convert_audio node
999
+ id_attr = node.id ? %( id="#{node.id}") : ''
1000
+ target = node.media_uri node.attr 'target'
1001
+ register_media_file node, target, 'audio'
1002
+ title_element = node.title? ? %(\n<figcaption>#{node.captioned_title}</figcaption>) : ''
1003
+
1004
+ autoplay_attr = (node.option? 'autoplay') ? ' autoplay="autoplay"' : ''
1005
+ controls_attr = (node.option? 'nocontrols') ? '' : ' controls="controls"'
1006
+ loop_attr = (node.option? 'loop') ? ' loop="loop"' : ''
1007
+
1008
+ start_t = node.attr 'start'
1009
+ end_t = node.attr 'end'
1010
+ if start_t || end_t
1011
+ time_anchor = %(#t=#{start_t || ''}#{end_t ? ",#{end_t}" : ''})
1012
+ else
1013
+ time_anchor = ''
1014
+ end
1015
+
1016
+ %(<figure#{id_attr} class="audioblock#{prepend_space node.role}">#{title_element}
1017
+ <div class="content">
1018
+ <audio src="#{target}#{time_anchor}"#{autoplay_attr}#{controls_attr}#{loop_attr}>
1019
+ <div>Your Reading System does not support (this) audio.</div>
1020
+ </audio>
1021
+ </div>
1022
+ </figure>)
1023
+ end
1024
+
1025
+ # TODO: Support multiple video files in different formats for a single video
1026
+ def convert_video node
1027
+ id_attr = node.id ? %( id="#{node.id}") : ''
1028
+ target = node.media_uri node.attr 'target'
1029
+ register_media_file node, target, 'video'
1030
+ title_element = node.title? ? %(\n<figcaption>#{node.captioned_title}</figcaption>) : ''
1031
+
1032
+ width_attr = (node.attr? 'width') ? %( width="#{node.attr 'width'}") : ''
1033
+ height_attr = (node.attr? 'height') ? %( height="#{node.attr 'height'}") : ''
1034
+ autoplay_attr = (node.option? 'autoplay') ? ' autoplay="autoplay"' : ''
1035
+ controls_attr = (node.option? 'nocontrols') ? '' : ' controls="controls"'
1036
+ loop_attr = (node.option? 'loop') ? ' loop="loop"' : ''
1037
+
1038
+ start_t = node.attr 'start'
1039
+ end_t = node.attr 'end'
1040
+ if start_t || end_t
1041
+ time_anchor = %(#t=#{start_t || ''}#{end_t ? ",#{end_t}" : ''})
1042
+ else
1043
+ time_anchor = ''
1044
+ end
1045
+
1046
+ if (poster = node.attr 'poster').nil_or_empty?
1047
+ poster_attr = ''
1048
+ else
1049
+ poster = node.media_uri poster
1050
+ register_media_file node, poster, 'image'
1051
+ poster_attr = %( poster="#{poster}")
1052
+ end
1053
+
1054
+ %(<figure#{id_attr} class="video#{prepend_space node.role}">#{title_element}
1055
+ <div class="content">
1056
+ <video src="#{target}#{time_anchor}"#{width_attr}#{height_attr}#{autoplay_attr}#{poster_attr}#{controls_attr}#{loop_attr}>
1057
+ <div>Your Reading System does not support (this) video.</div>
1058
+ </video>
1059
+ </div>
1060
+ </figure>)
1061
+ end
1062
+
1063
+ def convert_image node
1064
+ target = node.image_uri node.attr 'target'
1065
+ register_media_file node, target, 'image'
1066
+ id_attr = node.id ? %( id="#{node.id}") : ''
1067
+ title_element = node.title? ? %(\n<figcaption>#{node.captioned_title}</figcaption>) : ''
1068
+ img_attrs = resolve_image_attrs node
654
1069
  %(<figure#{id_attr} class="image#{prepend_space node.role}">
655
1070
  <div class="content">
656
- <img src="#{node.image_uri node.attr('target')}" #{img_attrs * ' '}/>
657
- </div>#{node.title? ? %(
658
- <figcaption>#{node.captioned_title}</figcaption>) : ''}
1071
+ <img src="#{target}"#{prepend_space img_attrs * ' '} />
1072
+ </div>#{title_element}
659
1073
  </figure>)
660
1074
  end
661
1075
 
1076
+ def get_enclosing_chapter node
1077
+ loop do
1078
+ return nil if node.nil?
1079
+ return node unless get_chapter_name(node).nil?
1080
+ node = node.parent
1081
+ end
1082
+ end
1083
+
662
1084
  def convert_inline_anchor node
663
- target = node.target
664
1085
  case node.type
665
- when :xref # TODO: would be helpful to know what type the target is (e.g., bibref)
666
- doc, refid, text, path = node.document, ((node.attr 'refid') || target), node.text, (node.attr 'path')
667
- # NOTE if path is non-nil, we have an inter-document xref
668
- # QUESTION should we drop the id attribute for an inter-document xref?
669
- if path
670
- # ex. chapter-id#section-id
671
- if node.attr 'fragment'
672
- refdoc_id, refdoc_refid = refid.split '#', 2
673
- if refdoc_id == refdoc_refid
674
- target = target[0...(target.index '#')]
675
- id_attr = %( id="xref--#{refdoc_id}")
1086
+ when :xref
1087
+ doc, refid, target, text = node.document, node.attr('refid'), node.target, node.text
1088
+ id_attr = ''
1089
+
1090
+ if (path = node.attributes['path'])
1091
+ # NOTE non-nil path indicates this is an inter-document xref that's not included in current document
1092
+ text = node.text || path
1093
+ elsif refid == '#'
1094
+ logger.warn %(#{::File.basename doc.attr('docfile')}: <<chapter#>> xref syntax isn't supported anymore. Use either <<chapter>> or <<chapter#anchor>>)
1095
+ elsif refid
1096
+ ref = doc.references[:refs][refid]
1097
+ our_chapter = get_enclosing_chapter node
1098
+ ref_chapter = get_enclosing_chapter ref
1099
+ if ref_chapter
1100
+ ref_docname = get_chapter_name ref_chapter
1101
+ if ref_chapter == our_chapter
1102
+ # ref within same chapter file
1103
+ id_attr = %( id="xref-#{refid}")
1104
+ target = %(##{refid})
1105
+ elsif refid == ref_docname
1106
+ # ref to top section of other chapter file
1107
+ id_attr = %( id="xref--#{refid}")
1108
+ target = %(#{refid}.xhtml)
676
1109
  else
677
- id_attr = %( id="xref--#{refdoc_id}--#{refdoc_refid}")
1110
+ # ref to section within other chapter file
1111
+ id_attr = %( id="xref--#{ref_docname}--#{refid}")
1112
+ target = %(#{ref_docname}.xhtml##{refid})
678
1113
  end
679
- # ex. chapter-id#
680
- else
681
- refdoc_id = refdoc_refid = refid
682
- # inflate key to spine item root (e.g., transform chapter-id to chapter-id#chapter-id)
683
- refid = %(#{refid}##{refid})
684
- id_attr = %( id="xref--#{refdoc_id}")
685
- end
686
- id_attr = '' unless @xrefs_seen.add? refid
687
- refdoc = doc.references[:spine_items].find {|it| refdoc_id == (it.id || (it.attr 'docname')) }
688
- if refdoc
689
- if (refs = refdoc.references[:refs]) && ::Asciidoctor::AbstractNode === (ref = refs[refdoc_refid])
690
- text ||= ::Asciidoctor::Document === ref ? ((ref.attr 'docreftext') || ref.doctitle) : ref.xreftext((@xrefstyle ||= (doc.attr 'xrefstyle')))
691
- elsif (xreftext = refdoc.references[:ids][refdoc_refid])
692
- text ||= xreftext
693
- else
694
- logger.warn %(#{::File.basename doc.attr('docfile')}: invalid reference to unknown anchor in #{refdoc_id} chapter: #{refdoc_refid})
695
- end
696
- else
697
- logger.warn %(#{::File.basename doc.attr('docfile')}: invalid reference to anchor in unknown chapter: #{refdoc_id})
698
- end
699
- else
700
- id_attr = (@xrefs_seen.add? refid) ? %( id="xref-#{refid}") : ''
701
- if (refs = doc.references[:refs])
702
- if ::Asciidoctor::AbstractNode === (ref = refs[refid])
703
- xreftext = text || ref.xreftext((@xrefstyle ||= (doc.attr 'xrefstyle')))
704
- end
705
- else
706
- xreftext = doc.references[:ids][refid]
707
- end
708
1114
 
709
- if xreftext
710
- text ||= xreftext
1115
+ id_attr = '' unless @xrefs_seen.add? refid
1116
+ text ||= (ref.xreftext node.attr('xrefstyle', nil, true))
711
1117
  else
712
- # FIXME: we get false negatives for reference to bibref when using Asciidoctor < 1.5.6
713
- logger.warn %(#{::File.basename doc.attr('docfile')}: invalid reference to unknown local anchor (or valid bibref): #{refid})
1118
+ logger.warn %(#{::File.basename doc.attr('docfile')}: invalid reference to unknown anchor: #{refid})
714
1119
  end
715
1120
  end
1121
+
716
1122
  %(<a#{id_attr} href="#{target}" class="xref">#{text || "[#{refid}]"}</a>)
717
1123
  when :ref
718
- %(<a id="#{target}"></a>)
1124
+ # NOTE id is used instead of target starting in Asciidoctor 2.0.0
1125
+ %(<a id="#{node.target || node.id}"></a>)
719
1126
  when :link
720
- %(<a href="#{target}" class="link">#{node.text}</a>)
1127
+ %(<a href="#{node.target}" class="link">#{node.text}</a>)
721
1128
  when :bibref
722
- if @xrefs_seen.include? target
723
- %(<a id="#{target}" href="#xref-#{target}">[#{target}]</a>)
1129
+ # NOTE reftext is no longer enclosed in [] starting in Asciidoctor 2.0.0
1130
+ # NOTE id is used instead of target starting in Asciidoctor 2.0.0
1131
+ if (reftext = node.reftext)
1132
+ reftext = %([#{reftext}]) unless reftext.start_with? '['
724
1133
  else
725
- %(<a id="#{target}"></a>[#{target}])
1134
+ reftext = %([#{node.target || node.id}])
726
1135
  end
1136
+ %(<a id="#{node.target || node.id}"></a>#{reftext})
1137
+ else
1138
+ logger.warn %(unknown anchor type: #{node.type.inspect})
1139
+ nil
727
1140
  end
728
1141
  end
729
1142
 
@@ -761,16 +1174,11 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
761
1174
  %(<i class="#{i_classes * ' '}"></i>)
762
1175
  else
763
1176
  target = node.image_uri node.target
764
- img_attrs = [%(alt="#{node.attr 'alt'}"), %(class="inline#{node.role? ? " #{node.role}" : ''}")]
765
- if target.end_with? '.svg'
766
- img_attrs << %(style="width: #{node.attr 'scaledwidth', '100%'}")
767
- # TODO: make this a convenience method on document
768
- epub_properties = (node.document.attributes['epub-properties'] ||= [])
769
- epub_properties << 'svg' unless epub_properties.include? 'svg'
770
- elsif node.attr? 'scaledwidth'
771
- img_attrs << %(style="width: #{node.attr 'scaledwidth'}")
772
- end
773
- %(<img src="#{target}" #{img_attrs * ' '}/>)
1177
+ register_media_file node, target, 'image'
1178
+
1179
+ img_attrs = resolve_image_attrs node
1180
+ img_attrs << %(class="inline#{prepend_space node.role}")
1181
+ %(<img src="#{target}"#{prepend_space img_attrs * ' '}/>)
774
1182
  end
775
1183
  end
776
1184
 
@@ -802,25 +1210,32 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
802
1210
  end
803
1211
 
804
1212
  def convert_inline_quoted node
805
- case node.type
806
- when :strong
807
- %(<strong>#{node.text}</strong>)
808
- when :emphasis
809
- %(<em>#{node.text}</em>)
810
- when :monospaced
811
- %(<code class="literal">#{node.text}</code>)
812
- when :double
813
- #%(&#x201c;#{node.text}&#x201d;)
814
- %(“#{node.text}”)
815
- when :single
816
- #%(&#x2018;#{node.text}&#x2019;)
817
- %(‘#{node.text}’)
818
- when :superscript
819
- %(<sup>#{node.text}</sup>)
820
- when :subscript
821
- %(<sub>#{node.text}</sub>)
822
- else
823
- node.text
1213
+ open, close, tag = QUOTE_TAGS[node.type]
1214
+
1215
+ if node.type == :asciimath && asciimath_available?
1216
+ content = AsciiMath.parse(node.text).to_mathml 'mml:'
1217
+ else
1218
+ content = node.text
1219
+ end
1220
+
1221
+ node.add_role 'literal' if [:monospaced, :asciimath, :latexmath].include? node.type
1222
+
1223
+ if node.id
1224
+ class_attr = class_string node
1225
+ if tag
1226
+ %(#{open.chop} id="#{node.id}"#{class_attr}>#{content}#{close})
1227
+ else
1228
+ %(<span id="#{node.id}"#{class_attr}>#{open}#{content}#{close}</span>)
1229
+ end
1230
+ elsif role_valid_class? node.role
1231
+ class_attr = class_string node
1232
+ if tag
1233
+ %(#{open.chop}#{class_attr}>#{content}#{close})
1234
+ else
1235
+ %(<span#{class_attr}>#{open}#{content}#{close}</span>)
1236
+ end
1237
+ else
1238
+ %(#{open}#{content}#{close})
824
1239
  end
825
1240
  end
826
1241
 
@@ -854,6 +1269,481 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
854
1269
  def prepend_space value
855
1270
  value ? %( #{value}) : ''
856
1271
  end
1272
+
1273
+ def add_theme_assets doc
1274
+ format = @format
1275
+ workdir = if doc.attr? 'epub3-stylesdir'
1276
+ stylesdir = doc.attr 'epub3-stylesdir'
1277
+ # FIXME: make this work for Windows paths!!
1278
+ if stylesdir.start_with? '/'
1279
+ stylesdir
1280
+ else
1281
+ docdir = doc.attr 'docdir', '.'
1282
+ docdir = '.' if docdir.empty?
1283
+ ::File.join docdir, stylesdir
1284
+ end
1285
+ else
1286
+ ::File.join DATA_DIR, 'styles'
1287
+ end
1288
+
1289
+ # TODO: improve design/UX of custom theme functionality, including custom fonts
1290
+
1291
+ if format == :kf8
1292
+ # NOTE add layer of indirection so Kindle Direct Publishing (KDP) doesn't strip font-related CSS rules
1293
+ @book.add_item 'styles/epub3.css', content: '@import url("epub3-proxied.css");'.to_ios
1294
+ @book.add_item 'styles/epub3-css3-only.css', content: '@import url("epub3-css3-only-proxied.css");'.to_ios
1295
+ @book.add_item 'styles/epub3-proxied.css', content: (postprocess_css_file ::File.join(workdir, 'epub3.css'), format)
1296
+ @book.add_item 'styles/epub3-css3-only-proxied.css', content: (postprocess_css_file ::File.join(workdir, 'epub3-css3-only.css'), format)
1297
+ else
1298
+ @book.add_item 'styles/epub3.css', content: (postprocess_css_file ::File.join(workdir, 'epub3.css'), format)
1299
+ @book.add_item 'styles/epub3-css3-only.css', content: (postprocess_css_file ::File.join(workdir, 'epub3-css3-only.css'), format)
1300
+ end
1301
+
1302
+ syntax_hl = doc.syntax_highlighter
1303
+ if syntax_hl&.write_stylesheet? doc
1304
+ Dir.mktmpdir do |dir|
1305
+ syntax_hl.write_stylesheet doc, dir
1306
+ Pathname.glob(dir + '/**/*').map do |filename|
1307
+ # Workaround for https://github.com/skoji/gepub/pull/117
1308
+ filename.open do |f|
1309
+ @book.add_item filename.basename.to_s, content: f
1310
+ end if filename.file?
1311
+ end
1312
+ end
1313
+ end
1314
+
1315
+ font_files, font_css = select_fonts ::File.join(DATA_DIR, 'styles/epub3-fonts.css'), (doc.attr 'scripts', 'latin')
1316
+ @book.add_item 'styles/epub3-fonts.css', content: font_css
1317
+ unless font_files.empty?
1318
+ # NOTE metadata property in oepbs package manifest doesn't work; must use proprietary iBooks file instead
1319
+ #(@book.metadata.add_metadata 'meta', 'true')['property'] = 'ibooks:specified-fonts' unless format == :kf8
1320
+ @book.add_optional_file 'META-INF/com.apple.ibooks.display-options.xml', '<?xml version="1.0" encoding="UTF-8"?>
1321
+ <display_options>
1322
+ <platform name="*">
1323
+ <option name="specified-fonts">true</option>
1324
+ </platform>
1325
+ </display_options>'.to_ios unless format == :kf8
1326
+
1327
+ font_files.each do |font_file|
1328
+ @book.add_item font_file, content: File.join(DATA_DIR, font_file)
1329
+ end
1330
+ end
1331
+ nil
1332
+ end
1333
+
1334
+ def add_cover_page doc
1335
+ return nil if (image_path = doc.attr 'front-cover-image').nil?
1336
+
1337
+ imagesdir = (doc.attr 'imagesdir', '.').chomp '/'
1338
+ imagesdir = (imagesdir == '.' ? '' : %(#{imagesdir}/))
1339
+
1340
+ image_attrs = {}
1341
+ if (image_path.include? ':') && image_path =~ ImageMacroRx
1342
+ logger.warn %(deprecated block macro syntax detected in front-cover-image attribute) if image_path.start_with? 'image::'
1343
+ image_path = %(#{imagesdir}#{$1})
1344
+ (::Asciidoctor::AttributeList.new $2).parse_into image_attrs, %w(alt width height) unless $2.empty?
1345
+ end
1346
+
1347
+ image_href = %(#{imagesdir}jacket/cover#{::File.extname image_path})
1348
+
1349
+ workdir = doc.attr 'docdir'
1350
+ workdir = '.' if workdir.nil_or_empty?
1351
+
1352
+ begin
1353
+ @book.add_item(image_href, content: File.join(workdir, image_path)).cover_image
1354
+ rescue => e
1355
+ logger.error %(#{::File.basename doc.attr('docfile')}: error adding front cover image. Make sure that :front-cover-image: attribute points to a valid image file. #{e})
1356
+ return nil
1357
+ end
1358
+
1359
+ return nil if @format == :kf8
1360
+
1361
+ unless !image_attrs.empty? && (width = image_attrs['width']) && (height = image_attrs['height'])
1362
+ width, height = 1050, 1600
1363
+ end
1364
+
1365
+ # NOTE SVG wrapper maintains aspect ratio and confines image to view box
1366
+ content = %(<!DOCTYPE html>
1367
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en" lang="en">
1368
+ <head>
1369
+ <meta charset="UTF-8"/>
1370
+ <title>#{sanitize_doctitle_xml doc, :cdata}</title>
1371
+ <style type="text/css">
1372
+ @page {
1373
+ margin: 0;
1374
+ }
1375
+ html {
1376
+ margin: 0 !important;
1377
+ padding: 0 !important;
1378
+ }
1379
+ body {
1380
+ margin: 0;
1381
+ padding: 0 !important;
1382
+ text-align: center;
1383
+ }
1384
+ body > svg {
1385
+ /* prevent bleed onto second page (removes descender space) */
1386
+ display: block;
1387
+ }
1388
+ </style>
1389
+ </head>
1390
+ <body epub:type="cover"><svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
1391
+ width="100%" height="100%" viewBox="0 0 #{width} #{height}" preserveAspectRatio="xMidYMid meet">
1392
+ <image width="#{width}" height="#{height}" xlink:href="#{image_href}"/>
1393
+ </svg></body>
1394
+ </html>).to_ios
1395
+
1396
+ @book.add_ordered_item 'cover.xhtml', content: content, id: 'cover'
1397
+ end
1398
+
1399
+ def get_frontmatter_files doc, workdir
1400
+ if doc.attr? 'epub3-frontmatterdir'
1401
+ fmdir = doc.attr 'epub3-frontmatterdir'
1402
+ fmglob = 'front-matter.*\.html'
1403
+ fm_path = File.join workdir, fmdir
1404
+ unless Dir.exist? fm_path
1405
+ logger.warn %(#{File.basename doc.attr('docfile')}: directory specified by 'epub3-frontmattderdir' doesn't exist! Ignoring ...)
1406
+ return []
1407
+ end
1408
+ fms = Dir.entries(fm_path).delete_if {|x| !x.match fmglob }.sort.map {|y| File.join fm_path, y }
1409
+ if fms && !fms.empty?
1410
+ fms
1411
+ else
1412
+ logger.warn %(#{File.basename doc.attr('docfile')}: directory specified by 'epub3-frontmattderdir' contains no suitable files! Ignoring ...)
1413
+ []
1414
+ end
1415
+ elsif File.exist? File.join workdir, 'front-matter.html'
1416
+ [File.join(workdir, 'front-matter.html')]
1417
+ else
1418
+ []
1419
+ end
1420
+ end
1421
+
1422
+ def add_front_matter_page doc
1423
+ workdir = doc.attr 'docdir'
1424
+ workdir = '.' if workdir.nil_or_empty?
1425
+
1426
+ result = nil
1427
+ get_frontmatter_files(doc, workdir).each do |front_matter|
1428
+ front_matter_content = ::File.read front_matter
1429
+
1430
+ front_matter_file = File.basename front_matter, '.html'
1431
+ item = @book.add_ordered_item "#{front_matter_file}.xhtml", content: (postprocess_xhtml front_matter_content)
1432
+ item.add_property 'svg' if SvgImgSniffRx =~ front_matter_content
1433
+ # Store link to first frontmatter page
1434
+ result = item if result.nil?
1435
+
1436
+ front_matter_content.scan ImgSrcScanRx do
1437
+ @book.add_item $1, content: File.join(File.dirname(front_matter), $1)
1438
+ end
1439
+ end
1440
+
1441
+ result
1442
+ end
1443
+
1444
+ def add_profile_images doc, usernames
1445
+ imagesdir = (doc.attr 'imagesdir', '.').chomp '/'
1446
+ imagesdir = (imagesdir == '.' ? nil : %(#{imagesdir}/))
1447
+
1448
+ @book.add_item %(#{imagesdir}avatars/default.jpg), content: ::File.join(DATA_DIR, 'images/default-avatar.jpg')
1449
+ @book.add_item %(#{imagesdir}headshots/default.jpg), content: ::File.join(DATA_DIR, 'images/default-headshot.jpg')
1450
+
1451
+ workdir = (workdir = doc.attr 'docdir').nil_or_empty? ? '.' : workdir
1452
+
1453
+ usernames.each do |username|
1454
+ avatar = %(#{imagesdir}avatars/#{username}.jpg)
1455
+ if ::File.readable? (resolved_avatar = (::File.join workdir, avatar))
1456
+ @book.add_item avatar, content: resolved_avatar
1457
+ else
1458
+ logger.error %(avatar for #{username} not found or readable: #{avatar}; falling back to default avatar)
1459
+ @book.add_item avatar, content: ::File.join(DATA_DIR, 'images/default-avatar.jpg')
1460
+ end
1461
+
1462
+ headshot = %(#{imagesdir}headshots/#{username}.jpg)
1463
+ if ::File.readable? (resolved_headshot = (::File.join workdir, headshot))
1464
+ @book.add_item headshot, content: resolved_headshot
1465
+ elsif doc.attr? 'builder', 'editions'
1466
+ logger.error %(headshot for #{username} not found or readable: #{headshot}; falling back to default headshot)
1467
+ @book.add_item headshot, content: ::File.join(DATA_DIR, 'images/default-headshot.jpg')
1468
+ end
1469
+ end
1470
+ nil
1471
+ end
1472
+
1473
+ def nav_doc doc, items, landmarks, depth
1474
+ lines = [%(<!DOCTYPE html>
1475
+ <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}">
1476
+ <head>
1477
+ <meta charset="UTF-8"/>
1478
+ <title>#{sanitize_doctitle_xml doc, :cdata}</title>
1479
+ <link rel="stylesheet" type="text/css" href="styles/epub3.css"/>
1480
+ <link rel="stylesheet" type="text/css" href="styles/epub3-css3-only.css" media="(min-device-width: 0px)"/>
1481
+ </head>
1482
+ <body>
1483
+ <section class="chapter">
1484
+ <header>
1485
+ <div class="chapter-header"><h1 class="chapter-title"><small class="subtitle">#{doc.attr 'toc-title'}</small></h1></div>
1486
+ </header>
1487
+ <nav epub:type="toc" id="toc">)]
1488
+ lines << (nav_level items, [depth, 0].max)
1489
+ lines << '</nav>'
1490
+
1491
+ unless landmarks.empty?
1492
+ lines << '
1493
+ <nav epub:type="landmarks" id="landmarks" hidden="hidden">
1494
+ <ol>'
1495
+ landmarks.each do |landmark|
1496
+ lines << %(<li><a epub:type="#{landmark[:type]}" href="#{landmark[:href]}">#{landmark[:title]}</a></li>)
1497
+ end
1498
+ lines << '
1499
+ </ol>
1500
+ </nav>'
1501
+ end
1502
+ lines << '
1503
+ </section>
1504
+ </body>
1505
+ </html>'
1506
+ lines * LF
1507
+ end
1508
+
1509
+ def nav_level items, depth, state = {}
1510
+ lines = []
1511
+ lines << '<ol>'
1512
+ items.each do |item|
1513
+ #index = (state[:index] = (state.fetch :index, 0) + 1)
1514
+ if (chapter_name = get_chapter_name item).nil?
1515
+ item_label = sanitize_xml get_numbered_title(item), :pcdata
1516
+ item_href = %(#{state[:content_doc_href]}##{item.id})
1517
+ else
1518
+ # NOTE we sanitize the chapter titles because we use formatting to control layout
1519
+ if item.context == :document
1520
+ item_label = sanitize_doctitle_xml item, :cdata
1521
+ else
1522
+ item_label = sanitize_xml get_numbered_title(item), :cdata
1523
+ end
1524
+ item_href = (state[:content_doc_href] = %(#{chapter_name}.xhtml))
1525
+ end
1526
+ lines << %(<li><a href="#{item_href}">#{item_label}</a>)
1527
+ if depth == 0 || (child_sections = item.sections).empty?
1528
+ lines[-1] = %(#{lines[-1]}</li>)
1529
+ else
1530
+ lines << (nav_level child_sections, depth - 1, state)
1531
+ lines << '</li>'
1532
+ end
1533
+ state.delete :content_doc_href unless chapter_name.nil?
1534
+ end
1535
+ lines << '</ol>'
1536
+ lines * LF
1537
+ end
1538
+
1539
+ def ncx_doc doc, items, depth
1540
+ # TODO: populate docAuthor element based on unique authors in work
1541
+ lines = [%(<?xml version="1.0" encoding="utf-8"?>
1542
+ <ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1" xml:lang="#{doc.attr 'lang', 'en'}">
1543
+ <head>
1544
+ <meta name="dtb:uid" content="#{@book.identifier}"/>
1545
+ %{depth}
1546
+ <meta name="dtb:totalPageCount" content="0"/>
1547
+ <meta name="dtb:maxPageNumber" content="0"/>
1548
+ </head>
1549
+ <docTitle><text>#{sanitize_doctitle_xml doc, :cdata}</text></docTitle>
1550
+ <navMap>)]
1551
+ lines << (ncx_level items, depth, state = {})
1552
+ lines[0] = lines[0].sub '%{depth}', %(<meta name="dtb:depth" content="#{state[:max_depth]}"/>)
1553
+ lines << %(</navMap>
1554
+ </ncx>)
1555
+ lines * LF
1556
+ end
1557
+
1558
+ def ncx_level items, depth, state = {}
1559
+ lines = []
1560
+ state[:max_depth] = (state.fetch :max_depth, 0) + 1
1561
+ items.each do |item|
1562
+ index = (state[:index] = (state.fetch :index, 0) + 1)
1563
+ item_id = %(nav_#{index})
1564
+ if (chapter_name = get_chapter_name item).nil?
1565
+ item_label = sanitize_xml get_numbered_title(item), :cdata
1566
+ item_href = %(#{state[:content_doc_href]}##{item.id})
1567
+ else
1568
+ if item.context == :document
1569
+ item_label = sanitize_doctitle_xml item, :cdata
1570
+ else
1571
+ item_label = sanitize_xml get_numbered_title(item), :cdata
1572
+ end
1573
+ item_href = (state[:content_doc_href] = %(#{chapter_name}.xhtml))
1574
+ end
1575
+ lines << %(<navPoint id="#{item_id}" playOrder="#{index}">)
1576
+ lines << %(<navLabel><text>#{item_label}</text></navLabel>)
1577
+ lines << %(<content src="#{item_href}"/>)
1578
+ unless depth == 0 || (child_sections = item.sections).empty?
1579
+ lines << (ncx_level child_sections, depth - 1, state)
1580
+ end
1581
+ lines << %(</navPoint>)
1582
+ state.delete :content_doc_href unless chapter_name.nil?
1583
+ end
1584
+ lines * LF
1585
+ end
1586
+
1587
+ # Swap fonts in CSS based on the value of the document attribute 'scripts',
1588
+ # then return the list of fonts as well as the font CSS.
1589
+ def select_fonts filename, scripts = 'latin'
1590
+ font_css = ::File.read filename
1591
+ font_css = font_css.gsub(/(?<=-)latin(?=\.ttf\))/, scripts) unless scripts == 'latin'
1592
+
1593
+ # match CSS font urls in the forms of:
1594
+ # src: url(../fonts/notoserif-regular-latin.ttf);
1595
+ # src: url(../fonts/notoserif-regular-latin.ttf) format("truetype");
1596
+ font_list = font_css.scan(/url\(\.\.\/([^)]+\.ttf)\)/).flatten
1597
+
1598
+ [font_list, font_css.to_ios]
1599
+ end
1600
+
1601
+ def postprocess_css_file filename, format
1602
+ return filename unless format == :kf8
1603
+ postprocess_css ::File.read(filename), format
1604
+ end
1605
+
1606
+ def postprocess_css content, format
1607
+ return content.to_ios unless format == :kf8
1608
+ # TODO: convert regular expressions to constants
1609
+ content
1610
+ .gsub(/^ -webkit-column-break-.*\n/, '')
1611
+ .gsub(/^ max-width: .*\n/, '')
1612
+ .to_ios
1613
+ end
1614
+
1615
+ # NOTE Kindle requires that
1616
+ # <meta charset="utf-8"/>
1617
+ # be converted to
1618
+ # <meta http-equiv="Content-Type" content="application/xml+xhtml; charset=UTF-8"/>
1619
+ def postprocess_xhtml content
1620
+ return content.to_ios unless @format == :kf8
1621
+ # TODO: convert regular expressions to constants
1622
+ content
1623
+ .gsub(/<meta charset="(.+?)"\/>/, '<meta http-equiv="Content-Type" content="application/xml+xhtml; charset=\1"/>')
1624
+ .gsub(/<img([^>]+) style="width: (\d\d)%;"/, '<img\1 style="width: \2%; height: \2%;"')
1625
+ .gsub(/<script type="text\/javascript">.*?<\/script>\n?/m, '')
1626
+ .to_ios
1627
+ end
1628
+
1629
+ def get_kindlegen_command kindlegen_path
1630
+ unless kindlegen_path.nil?
1631
+ logger.debug %(Using ebook-kindlegen-path attribute: #{kindlegen_path})
1632
+ return [kindlegen_path]
1633
+ end
1634
+
1635
+ unless (result = ENV['KINDLEGEN']).nil?
1636
+ logger.debug %(Using KINDLEGEN env variable: #{result})
1637
+ return [result]
1638
+ end
1639
+
1640
+ begin
1641
+ require 'kindlegen' unless defined? ::Kindlegen
1642
+ result = ::Kindlegen.command.to_s
1643
+ logger.debug %(Using KindleGen from gem: #{result})
1644
+ [result]
1645
+ rescue LoadError => e
1646
+ logger.debug %(#{e}; Using KindleGen from PATH)
1647
+ [%(kindlegen#{::Gem.win_platform? ? '.exe' : ''})]
1648
+ end
1649
+ end
1650
+
1651
+ def distill_epub_to_mobi epub_file, target, compress, kindlegen_path
1652
+ mobi_file = ::File.basename target.sub(EpubExtensionRx, '.mobi')
1653
+ compress_flag = KindlegenCompression[compress ? (compress.empty? ? '1' : compress.to_s) : '0']
1654
+
1655
+ argv = get_kindlegen_command(kindlegen_path) + ['-dont_append_source', compress_flag, '-o', mobi_file, epub_file].compact
1656
+ begin
1657
+ # This duplicates Kindlegen.run, but we want to override executable
1658
+ out, err, res = Open3.capture3(*argv) do |r|
1659
+ r.force_encoding 'UTF-8' if ::Gem.win_platform? && r.respond_to?(:force_encoding)
1660
+ end
1661
+ rescue Errno::ENOENT => e
1662
+ 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
1663
+ end
1664
+
1665
+ out.each_line do |line|
1666
+ log_line line
1667
+ end
1668
+ err.each_line do |line|
1669
+ log_line line
1670
+ end
1671
+
1672
+ output_file = ::File.join ::File.dirname(epub_file), mobi_file
1673
+ if res.success?
1674
+ logger.debug %(Wrote MOBI to #{output_file})
1675
+ else
1676
+ logger.error %(KindleGen failed to write MOBI to #{output_file})
1677
+ end
1678
+ end
1679
+
1680
+ def get_epubcheck_command epubcheck_path
1681
+ unless epubcheck_path.nil?
1682
+ logger.debug %(Using ebook-epubcheck-path attribute: #{epubcheck_path})
1683
+ return [epubcheck_path]
1684
+ end
1685
+
1686
+ unless (result = ENV['EPUBCHECK']).nil?
1687
+ logger.debug %(Using EPUBCHECK env variable: #{result})
1688
+ return [result]
1689
+ end
1690
+
1691
+ begin
1692
+ result = ::Gem.bin_path 'epubcheck-ruby', 'epubcheck'
1693
+ logger.debug %(Using EPUBCheck from gem: #{result})
1694
+ [::Gem.ruby, result]
1695
+ rescue ::Gem::Exception => e
1696
+ logger.debug %(#{e}; Using EPUBCheck from PATH)
1697
+ ['epubcheck']
1698
+ end
1699
+ end
1700
+
1701
+ def validate_epub epub_file, epubcheck_path
1702
+ argv = get_epubcheck_command(epubcheck_path) + ['-w', epub_file]
1703
+ begin
1704
+ out, err, res = Open3.capture3(*argv)
1705
+ rescue Errno::ENOENT => e
1706
+ 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
1707
+ end
1708
+
1709
+ out.each_line do |line|
1710
+ logger.info line
1711
+ end
1712
+ err.each_line do |line|
1713
+ log_line line
1714
+ end
1715
+
1716
+ logger.error %(EPUB validation failed: #{epub_file}) unless res.success?
1717
+ end
1718
+
1719
+ def log_line line
1720
+ line = line.strip
1721
+
1722
+ if line =~ /^fatal/i
1723
+ logger.fatal line
1724
+ elsif line =~ /^error/i
1725
+ logger.error line
1726
+ elsif line =~ /^warning/i
1727
+ logger.warn line
1728
+ else
1729
+ logger.info line
1730
+ end
1731
+ end
1732
+
1733
+ private
1734
+
1735
+ def class_string node
1736
+ role = node.role
1737
+
1738
+ return '' unless role_valid_class? role
1739
+
1740
+ %( class="#{role}")
1741
+ end
1742
+
1743
+ # Handles asciidoctor 1.5.6 quirk when role can be parent
1744
+ def role_valid_class? role
1745
+ role.is_a? String
1746
+ end
857
1747
  end
858
1748
 
859
1749
  class DocumentIdGenerator
@@ -866,6 +1756,7 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
866
1756
  InvalidIdCharsRx = /[^[:word:]]+/
867
1757
  LeadingDigitRx = /^[[:digit:]]/
868
1758
  end
1759
+
869
1760
  class << self
870
1761
  def generate_id doc, pre = nil, sep = nil
871
1762
  synthetic = false
@@ -914,19 +1805,22 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
914
1805
  end
915
1806
  end
916
1807
 
917
- require_relative 'packager'
918
-
919
1808
  Extensions.register do
920
1809
  if (document = @document).backend == 'epub3'
921
- document.attributes['spine'] = ''
922
1810
  document.set_attribute 'listing-caption', 'Listing'
923
- # pygments.rb hangs on JRuby for Windows, see https://github.com/asciidoctor/asciidoctor-epub3/issues/253
924
- if !(::RUBY_ENGINE == 'jruby' && Gem.win_platform?) && (Gem.try_activate 'pygments.rb')
925
- if document.set_attribute 'source-highlighter', 'pygments'
926
- document.set_attribute 'pygments-css', 'style'
927
- document.set_attribute 'pygments-style', 'bw'
928
- end
1811
+
1812
+ # TODO: bw theme for CodeRay
1813
+ document.set_attribute 'pygments-style', 'bw' unless document.attr? 'pygments-style'
1814
+ document.set_attribute 'rouge-style', 'bw' unless document.attr? 'rouge-style'
1815
+
1816
+ # Old asciidoctor versions do not have public API for writing highlighter CSS file
1817
+ # So just use inline CSS there.
1818
+ unless Document.supports_syntax_highlighter?
1819
+ document.set_attribute 'coderay-css', 'style'
1820
+ document.set_attribute 'pygments-css', 'style'
1821
+ document.set_attribute 'rouge-css', 'style'
929
1822
  end
1823
+
930
1824
  case (ebook_format = document.attributes['ebook-format'])
931
1825
  when 'epub3', 'kf8'
932
1826
  # all good
@@ -937,8 +1831,6 @@ document.addEventListener('DOMContentLoaded', function(event, reader) {
937
1831
  ebook_format = document.attributes['ebook-format'] = 'epub3'
938
1832
  end
939
1833
  document.attributes[%(ebook-format-#{ebook_format})] = ''
940
- # Only fire SpineItemProcessor for top-level include directives
941
- include_processor SpineItemProcessor.new(document)
942
1834
  treeprocessor do
943
1835
  process do |doc|
944
1836
  doc.id = DocumentIdGenerator.generate_id doc, (doc.attr 'idprefix'), (doc.attr 'idseparator')