asciidoctor-epub3 1.5.0.alpha.12

Sign up to get free protection for your applications and to get access to all the features.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +12 -0
  3. data/CHANGELOG.adoc +199 -0
  4. data/Gemfile +16 -0
  5. data/LICENSE.adoc +25 -0
  6. data/NOTICE.adoc +54 -0
  7. data/README.adoc +1001 -0
  8. data/Rakefile +5 -0
  9. data/asciidoctor-epub3.gemspec +42 -0
  10. data/bin/adb-push-ebook +35 -0
  11. data/bin/asciidoctor-epub3 +30 -0
  12. data/data/fonts/assorted-icons.ttf +0 -0
  13. data/data/fonts/fontawesome-icons.ttf +0 -0
  14. data/data/fonts/mplus1mn-bold-ascii.ttf +0 -0
  15. data/data/fonts/mplus1mn-bolditalic-ascii.ttf +0 -0
  16. data/data/fonts/mplus1mn-italic-ascii.ttf +0 -0
  17. data/data/fonts/mplus1mn-regular-ascii-conums.ttf +0 -0
  18. data/data/fonts/mplus1p-bold-latin-cyrillic.ttf +0 -0
  19. data/data/fonts/mplus1p-bold-latin-ext.ttf +0 -0
  20. data/data/fonts/mplus1p-bold-latin.ttf +0 -0
  21. data/data/fonts/mplus1p-bold-multilingual.ttf +0 -0
  22. data/data/fonts/mplus1p-light-latin-cyrillic.ttf +0 -0
  23. data/data/fonts/mplus1p-light-latin-ext.ttf +0 -0
  24. data/data/fonts/mplus1p-light-latin.ttf +0 -0
  25. data/data/fonts/mplus1p-light-multilingual.ttf +0 -0
  26. data/data/fonts/mplus1p-regular-latin-cyrillic.ttf +0 -0
  27. data/data/fonts/mplus1p-regular-latin-ext.ttf +0 -0
  28. data/data/fonts/mplus1p-regular-latin.ttf +0 -0
  29. data/data/fonts/mplus1p-regular-multilingual.ttf +0 -0
  30. data/data/fonts/notoserif-bold-latin-cyrillic.ttf +0 -0
  31. data/data/fonts/notoserif-bold-latin-ext.ttf +0 -0
  32. data/data/fonts/notoserif-bold-latin.ttf +0 -0
  33. data/data/fonts/notoserif-bold-multilingual.ttf +0 -0
  34. data/data/fonts/notoserif-bolditalic-latin-cyrillic.ttf +0 -0
  35. data/data/fonts/notoserif-bolditalic-latin-ext.ttf +0 -0
  36. data/data/fonts/notoserif-bolditalic-latin.ttf +0 -0
  37. data/data/fonts/notoserif-bolditalic-multilingual.ttf +0 -0
  38. data/data/fonts/notoserif-italic-latin-cyrillic.ttf +0 -0
  39. data/data/fonts/notoserif-italic-latin-ext.ttf +0 -0
  40. data/data/fonts/notoserif-italic-latin.ttf +0 -0
  41. data/data/fonts/notoserif-italic-multilingual.ttf +0 -0
  42. data/data/fonts/notoserif-regular-latin-cyrillic.ttf +0 -0
  43. data/data/fonts/notoserif-regular-latin-ext.ttf +0 -0
  44. data/data/fonts/notoserif-regular-latin.ttf +0 -0
  45. data/data/fonts/notoserif-regular-multilingual.ttf +0 -0
  46. data/data/images/default-avatar.jpg +0 -0
  47. data/data/images/default-avatar.png +0 -0
  48. data/data/images/default-avatar.svg +67 -0
  49. data/data/images/default-cover.svg +53 -0
  50. data/data/images/default-headshot.jpg +0 -0
  51. data/data/images/default-headshot.png +0 -0
  52. data/data/styles/color-palette.css +28 -0
  53. data/data/styles/epub3-css3-only.css +226 -0
  54. data/data/styles/epub3-fonts.css +94 -0
  55. data/data/styles/epub3.css +1266 -0
  56. data/lib/asciidoctor-epub3.rb +11 -0
  57. data/lib/asciidoctor-epub3/converter.rb +951 -0
  58. data/lib/asciidoctor-epub3/ext.rb +4 -0
  59. data/lib/asciidoctor-epub3/ext/asciidoctor.rb +3 -0
  60. data/lib/asciidoctor-epub3/ext/asciidoctor/logging_shim.rb +33 -0
  61. data/lib/asciidoctor-epub3/ext/core.rb +3 -0
  62. data/lib/asciidoctor-epub3/ext/core/string.rb +9 -0
  63. data/lib/asciidoctor-epub3/font_icon_map.rb +378 -0
  64. data/lib/asciidoctor-epub3/packager.rb +722 -0
  65. data/lib/asciidoctor-epub3/spine_item_processor.rb +92 -0
  66. data/lib/asciidoctor-epub3/version.rb +7 -0
  67. metadata +237 -0
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'asciidoctor'
4
+ require 'asciidoctor/extensions'
5
+ require 'gepub'
6
+ require_relative 'asciidoctor-epub3/ext'
7
+ require_relative 'asciidoctor-epub3/converter'
8
+ require_relative 'asciidoctor-epub3/packager'
9
+
10
+ # We need to be able to write files with unicode names. See https://github.com/asciidoctor/asciidoctor-epub3/issues/217
11
+ ::Zip.unicode_names = true
@@ -0,0 +1,951 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'spine_item_processor'
4
+ require_relative 'font_icon_map'
5
+
6
+ module Asciidoctor
7
+ module Epub3
8
+ # Public: The main converter for the epub3 backend that handles packaging the
9
+ # EPUB3 or KF8 publication file.
10
+ class Converter
11
+ include ::Asciidoctor::Converter
12
+ include ::Asciidoctor::Logging
13
+ include ::Asciidoctor::Writer
14
+
15
+ register_for 'epub3'
16
+
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 = []
39
+ 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})
46
+ end
47
+ end
48
+
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
53
+ end
54
+ end
55
+
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
61
+
62
+ register_for 'epub3-xhtml5'
63
+
64
+ LF = ?\n
65
+ NoBreakSpace = ' '
66
+ ThinNoBreakSpace = ' '
67
+ RightAngleQuote = '›'
68
+ CalloutStartNum = %(\u2460)
69
+
70
+ CharEntityRx = /&#(\d{2,6});/
71
+ XmlElementRx = /<\/?.+?>/
72
+ TrailingPunctRx = /[[:punct:]]$/
73
+
74
+ FromHtmlSpecialCharsMap = {
75
+ '&lt;' => '<',
76
+ '&gt;' => '>',
77
+ '&amp;' => '&',
78
+ }
79
+
80
+ FromHtmlSpecialCharsRx = /(?:#{FromHtmlSpecialCharsMap.keys * '|'})/
81
+
82
+ ToHtmlSpecialCharsMap = {
83
+ '&' => '&amp;',
84
+ '<' => '&lt;',
85
+ '>' => '&gt;',
86
+ }
87
+
88
+ ToHtmlSpecialCharsRx = /[#{ToHtmlSpecialCharsMap.keys.join}]/
89
+
90
+ def initialize backend, opts
91
+ super
92
+ basebackend 'html'
93
+ outfilesuffix '.xhtml'
94
+ htmlsyntax 'xml'
95
+ @xrefs_seen = ::Set.new
96
+ @icon_names = []
97
+ end
98
+
99
+ def convert node, name = nil, _opts = {}
100
+ method_name = %(convert_#{name ||= node.node_name})
101
+ if respond_to? method_name
102
+ send method_name, node
103
+ else
104
+ logger.warn %(conversion missing in backend #{@backend} for #{name})
105
+ end
106
+ end
107
+
108
+ def convert_document node
109
+ docid = node.id
110
+ pubtype = node.attr 'publication-type', 'book'
111
+
112
+ if (doctitle = node.doctitle partition: true, use_fallback: true).subtitle?
113
+ title = %(#{doctitle.main} )
114
+ subtitle = doctitle.subtitle
115
+ else
116
+ # HACK: until we get proper handling of title-only in CSS
117
+ title = ''
118
+ subtitle = doctitle.combined
119
+ end
120
+
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'
125
+ byline = ''
126
+ else
127
+ author = node.attr 'author'
128
+ username = node.attr 'username', 'default'
129
+ imagesdir = (node.references[:spine].attr 'imagesdir', '.').chomp '/'
130
+ imagesdir = imagesdir == '.' ? '' : %(#{imagesdir}/)
131
+ byline = %(<p class="byline"><img src="#{imagesdir}avatars/#{username}.jpg"/> <b class="author">#{author}</b></p>#{LF})
132
+ end
133
+
134
+ mark_last_paragraph node unless pubtype == 'book'
135
+ content = node.content
136
+
137
+ # NOTE must run after content is resolved
138
+ # TODO perhaps create dynamic CSS file?
139
+ if @icon_names.empty?
140
+ icon_css_head = ''
141
+ else
142
+ icon_defs = @icon_names.map {|name|
143
+ %(.i-#{name}::before { content: "#{FontIconMap[name.tr('-', '_').to_sym]}"; })
144
+ } * LF
145
+ icon_css_head = %(<style>
146
+ #{icon_defs}
147
+ </style>
148
+ )
149
+ end
150
+
151
+ # NOTE kindlegen seems to mangle the <header> element, so we wrap its content in a div
152
+ 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}">
154
+ <head>
155
+ <meta charset="UTF-8"/>
156
+ <title>#{doctitle_sanitized}</title>
157
+ <link rel="stylesheet" type="text/css" href="styles/epub3.css"/>
158
+ <link rel="stylesheet" type="text/css" href="styles/epub3-css3-only.css" media="(min-device-width: 0px)"/>
159
+ #{icon_css_head}<script type="text/javascript"><![CDATA[
160
+ document.addEventListener('DOMContentLoaded', function(event, reader) {
161
+ if (!(reader = navigator.epubReadingSystem)) {
162
+ if (navigator.userAgent.indexOf(' calibre/') >= 0) reader = { name: 'calibre-desktop' };
163
+ else if (window.parent == window || !(reader = window.parent.navigator.epubReadingSystem)) return;
164
+ }
165
+ document.body.setAttribute('class', reader.name.toLowerCase().replace(/ /g, '-'));
166
+ });
167
+ ]]></script>
168
+ </head>
169
+ <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})]
177
+
178
+ if node.footnotes?
179
+ # NOTE kindlegen seems to mangle the <footer> element, so we wrap its content in a div
180
+ lines << '<footer>
181
+ <div class="chapter-footer">
182
+ <div class="footnotes">'
183
+ node.footnotes.each do |footnote|
184
+ lines << %(<aside id="note-#{footnote.index}" epub:type="footnote">
185
+ <p><sup class="noteref"><a href="#noteref-#{footnote.index}">#{footnote.index}</a></sup> #{footnote.text}</p>
186
+ </aside>)
187
+ end
188
+ lines << '</div>
189
+ </div>
190
+ </footer>'
191
+ end
192
+
193
+ lines << '</section>
194
+ </body>
195
+ </html>'
196
+
197
+ lines * LF
198
+ end
199
+
200
+ # NOTE embedded is used for AsciiDoc table cell content
201
+ def convert_embedded node
202
+ node.content
203
+ end
204
+
205
+ 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
212
+ %(<section class="#{div_classes * ' '}" title="#{title_sanitized}"#{epub_type_attr}>
213
+ <h#{hlevel} id="#{node.id}">#{title}</h#{hlevel}>#{(content = node.content).empty? ? '' : %(
214
+ #{content})}
215
+ </section>)
216
+ else
217
+ # document has no level-0 heading and this heading serves as the document title
218
+ node.content
219
+ end
220
+ end
221
+
222
+ # TODO: support use of quote block as abstract
223
+ 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
231
+ end
232
+ end
233
+
234
+ def convert_open node
235
+ id_attr = node.id ? %( id="#{node.id}") : nil
236
+ class_attr = node.role ? %( class="#{node.role}") : nil
237
+ if id_attr || class_attr
238
+ %(<div#{id_attr}#{class_attr}>
239
+ #{output_content node}
240
+ </div>)
241
+ else
242
+ output_content node
243
+ end
244
+ end
245
+
246
+ def convert_abstract node
247
+ %(<div class="abstract" epub:type="preamble">
248
+ #{output_content node}
249
+ </div>)
250
+ end
251
+
252
+ def convert_paragraph node
253
+ role = node.role
254
+ # stack-head is the alternative to the default, inline-head (where inline means "run-in")
255
+ head_stop = node.attr 'head-stop', (role && (node.has_role? 'stack-head') ? nil : '.')
256
+ head = node.title? ? %(<strong class="head">#{title = node.title}#{head_stop && title !~ TrailingPunctRx ? head_stop : ''}</strong> ) : ''
257
+ if role
258
+ node.set_option 'hardbreaks' if node.has_role? 'signature'
259
+ %(<p class="#{role}">#{head}#{node.content}</p>)
260
+ else
261
+ %(<p>#{head}#{node.content}</p>)
262
+ end
263
+ end
264
+
265
+ def convert_pass node
266
+ content = node.content
267
+ if content == '<?hard-pagebreak?>'
268
+ '<hr epub:type="pagebreak" class="pagebreak"/>'
269
+ else
270
+ content
271
+ end
272
+ end
273
+
274
+ def convert_admonition node
275
+ id_attr = node.id ? %( id="#{node.id}") : ''
276
+ if node.title?
277
+ title = node.title
278
+ title_sanitized = xml_sanitize title
279
+ title_attr = %( title="#{node.caption}: #{title_sanitized}")
280
+ title_el = %(<h2>#{title}</h2>
281
+ )
282
+ else
283
+ title_attr = %( title="#{node.caption}")
284
+ title_el = ''
285
+ end
286
+
287
+ type = node.attr 'name'
288
+ epub_type = case type
289
+ when 'tip'
290
+ 'help'
291
+ when 'note'
292
+ 'note'
293
+ when 'important', 'warning', 'caution'
294
+ 'warning'
295
+ end
296
+ %(<aside#{id_attr} class="admonition #{type}"#{title_attr} epub:type="#{epub_type}">
297
+ #{title_el}<div class="content">
298
+ #{output_content node}
299
+ </div>
300
+ </aside>)
301
+ end
302
+
303
+ def convert_example node
304
+ id_attr = node.id ? %( id="#{node.id}") : ''
305
+ title_div = node.title? ? %(<div class="example-title">#{node.title}</div>
306
+ ) : ''
307
+ %(<div#{id_attr} class="example">
308
+ #{title_div}<div class="example-content">
309
+ #{output_content node}
310
+ </div>
311
+ </div>)
312
+ end
313
+
314
+ def convert_floating_title node
315
+ tag_name = %(h#{node.level + 1})
316
+ id_attribute = node.id ? %( id="#{node.id}") : ''
317
+ %(<#{tag_name}#{id_attribute} class="#{['discrete', node.role].compact * ' '}">#{node.title}</#{tag_name}>)
318
+ end
319
+
320
+ def convert_listing node
321
+ figure_classes = ['listing']
322
+ 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>
330
+ </figure>)
331
+ end
332
+
333
+ # QUESTION should we wrap the <pre> in either <div> or <figure>?
334
+ def convert_literal node
335
+ %(<pre class="screen">#{node.content}</pre>)
336
+ end
337
+
338
+ def convert_page_break _node
339
+ '<hr epub:type="pagebreak" class="pagebreak"/>'
340
+ end
341
+
342
+ def convert_thematic_break _node
343
+ '<hr class="thematicbreak"/>'
344
+ end
345
+
346
+ def convert_quote node
347
+ id_attr = %( id="#{node.id}") if node.id
348
+ class_attr = (role = node.role) ? %( class="blockquote #{role}") : ' class="blockquote"'
349
+
350
+ footer_content = []
351
+ if (attribution = node.attr 'attribution')
352
+ footer_content << attribution
353
+ end
354
+
355
+ if (citetitle = node.attr 'citetitle')
356
+ citetitle_sanitized = xml_sanitize citetitle
357
+ footer_content << %(<cite title="#{citetitle_sanitized}">#{citetitle}</cite>)
358
+ end
359
+
360
+ footer_content << %(<span class="context">#{node.title}</span>) if node.title?
361
+
362
+ footer_tag = footer_content.empty? ? '' : %(
363
+ <footer>~ #{footer_content * ' '}</footer>)
364
+ content = (output_content node).strip
365
+ %(<div#{id_attr}#{class_attr}>
366
+ <blockquote>
367
+ #{content}#{footer_tag}
368
+ </blockquote>
369
+ </div>)
370
+ end
371
+
372
+ def convert_verse node
373
+ id_attr = %( id="#{node.id}") if node.id
374
+ class_attr = (role = node.role) ? %( class="verse #{role}") : ' class="verse"'
375
+
376
+ footer_content = []
377
+ if (attribution = node.attr 'attribution')
378
+ footer_content << attribution
379
+ end
380
+
381
+ if (citetitle = node.attr 'citetitle')
382
+ citetitle_sanitized = xml_sanitize citetitle
383
+ footer_content << %(<cite title="#{citetitle_sanitized}">#{citetitle}</cite>)
384
+ end
385
+
386
+ footer_tag = !footer_content.empty? ? %(
387
+ <span class="attribution">~ #{footer_content * ', '}</span>) : ''
388
+ %(<div#{id_attr}#{class_attr}>
389
+ <pre>#{node.content}#{footer_tag}</pre>
390
+ </div>)
391
+ end
392
+
393
+ def convert_sidebar node
394
+ classes = ['sidebar']
395
+ if node.title?
396
+ classes << 'titled'
397
+ title = node.title
398
+ title_sanitized = xml_sanitize title
399
+ title_attr = %( title="#{title_sanitized}")
400
+ title_el = %(<h2>#{title}</h2>
401
+ )
402
+ else
403
+ title_attr = title_el = ''
404
+ end
405
+
406
+ %(<aside class="#{classes * ' '}"#{title_attr} epub:type="sidebar">
407
+ #{title_el}<div class="content">
408
+ #{output_content node}
409
+ </div>
410
+ </aside>)
411
+ end
412
+
413
+ def convert_table node
414
+ lines = [%(<div class="table">)]
415
+ lines << %(<div class="content">)
416
+ 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']}]
430
+ if (role = node.role)
431
+ table_classes << role
432
+ end
433
+ table_class_attr = %( class="#{table_classes * ' '}")
434
+ table_styles = []
435
+ table_styles << %(width: #{node.attr 'tablepcwidth'}%) unless (node.option? 'autowidth') && !(node.attr? 'width', nil, false)
436
+ table_style_attr = !table_styles.empty? ? %( style="#{table_styles * '; '}") : ''
437
+
438
+ lines << %(<table#{table_id_attr}#{table_class_attr}#{table_style_attr}>)
439
+ lines << %(<caption>#{node.captioned_title}</caption>) if node.title?
440
+ if (node.attr 'rowcount') > 0
441
+ 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
452
+ lines << '</colgroup>'
453
+ [:head, :foot, :body].reject {|tsec| node.rows[tsec].empty? }.each do |tsec|
454
+ lines << %(<t#{tsec}>)
455
+ node.rows[tsec].each do |row|
456
+ lines << '<tr>'
457
+ row.each do |cell|
458
+ if tsec == :head
459
+ cell_content = cell.text
460
+ else
461
+ case cell.style
462
+ when :asciidoc
463
+ cell_content = %(<div class="embed">#{cell.content}</div>)
464
+ when :verse
465
+ cell_content = %(<div class="verse">#{cell.text}</div>)
466
+ when :literal
467
+ cell_content = %(<div class="literal"><pre>#{cell.text}</pre></div>)
468
+ else
469
+ cell_content = ''
470
+ cell.content.each do |text|
471
+ cell_content = %(#{cell_content}<p>#{text}</p>)
472
+ end
473
+ end
474
+ end
475
+
476
+ 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
484
+ cell_class_attr = !cell_classes.empty? ? %( class="#{cell_classes * ' '}") : ''
485
+ cell_colspan_attr = cell.colspan ? %( colspan="#{cell.colspan}") : ''
486
+ cell_rowspan_attr = cell.rowspan ? %( rowspan="#{cell.rowspan}") : ''
487
+ cell_style_attr = (node.document.attr? 'cellbgcolor') ? %( style="background-color: #{node.document.attr 'cellbgcolor'}") : ''
488
+ lines << %(<#{cell_tag_name}#{cell_class_attr}#{cell_colspan_attr}#{cell_rowspan_attr}#{cell_style_attr}>#{cell_content}</#{cell_tag_name}>)
489
+ end
490
+ lines << '</tr>'
491
+ end
492
+ lines << %(</t#{tsec}>)
493
+ end
494
+ end
495
+ lines << '</table>
496
+ </div>
497
+ </div>'
498
+ lines * LF
499
+ end
500
+
501
+ def convert_colist node
502
+ lines = ['<div class="callout-list">
503
+ <ol>']
504
+ num = CalloutStartNum
505
+ node.items.each_with_index do |item, i|
506
+ lines << %(<li><i class="conum" data-value="#{i + 1}">#{num}</i> #{item.text}</li>)
507
+ num = num.next
508
+ end
509
+ lines << '</ol>
510
+ </div>'
511
+ end
512
+
513
+ # TODO: add complex class if list has nested blocks
514
+ def convert_dlist node
515
+ lines = []
516
+ case (style = node.style)
517
+ when 'itemized', 'ordered'
518
+ list_tag_name = style == 'itemized' ? 'ul' : 'ol'
519
+ role = node.role
520
+ 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
+ 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"' : ''}>)
526
+ node.items.each do |subjects, dd|
527
+ # consists of one term (a subject) and supporting content
528
+ subject = [*subjects].first.text
529
+ subject_plain = xml_sanitize subject, :plain
530
+ subject_element = %(<strong class="subject">#{subject}#{subject_stop && subject_plain !~ TrailingPunctRx ? subject_stop : ''}</strong>)
531
+ lines << '<li>'
532
+ if dd
533
+ # NOTE: must wrap remaining text in a span to help webkit justify the text properly
534
+ lines << %(<span class="principal">#{subject_element}#{dd.text? ? %( <span class="supporting">#{dd.text}</span>) : ''}</span>)
535
+ lines << dd.content if dd.blocks?
536
+ else
537
+ lines << %(<span class="principal">#{subject_element}</span>)
538
+ end
539
+ lines << '</li>'
540
+ end
541
+ lines << %(</#{list_tag_name}>
542
+ </div>)
543
+ else
544
+ lines << '<div class="description-list">
545
+ <dl>'
546
+ node.items.each do |terms, dd|
547
+ [*terms].each do |dt|
548
+ lines << %(<dt>
549
+ <span class="term">#{dt.text}</span>
550
+ </dt>)
551
+ end
552
+ next unless dd
553
+ lines << '<dd>'
554
+ if dd.blocks?
555
+ lines << %(<span class="principal">#{dd.text}</span>) if dd.text?
556
+ lines << dd.content
557
+ else
558
+ lines << %(<span class="principal">#{dd.text}</span>)
559
+ end
560
+ lines << '</dd>'
561
+ end
562
+ lines << '</dl>
563
+ </div>'
564
+ end
565
+ lines * LF
566
+ end
567
+
568
+ def convert_olist node
569
+ complex = false
570
+ div_classes = ['ordered-list', node.style, node.role].compact
571
+ ol_classes = [node.style, ((node.option? 'brief') ? 'brief' : nil)].compact
572
+ ol_class_attr = ol_classes.empty? ? '' : %( class="#{ol_classes * ' '}")
573
+ ol_start_attr = (node.attr? 'start') ? %( start="#{node.attr 'start'}") : ''
574
+ id_attribute = node.id ? %( id="#{node.id}") : ''
575
+ lines = [%(<div#{id_attribute} class="#{div_classes * ' '}">)]
576
+ lines << %(<h3 class="list-heading">#{node.title}</h3>) if node.title?
577
+ lines << %(<ol#{ol_class_attr}#{ol_start_attr}#{(node.option? 'reversed') ? ' reversed="reversed"' : ''}>)
578
+ node.items.each do |item|
579
+ lines << %(<li>
580
+ <span class="principal">#{item.text}</span>)
581
+ if item.blocks?
582
+ lines << item.content
583
+ complex = true unless item.blocks.size == 1 && ::Asciidoctor::List === item.blocks[0]
584
+ end
585
+ lines << '</li>'
586
+ end
587
+ if complex
588
+ div_classes << 'complex'
589
+ lines[0] = %(<div class="#{div_classes * ' '}">)
590
+ end
591
+ lines << '</ol>
592
+ </div>'
593
+ lines * LF
594
+ end
595
+
596
+ def convert_ulist node
597
+ complex = false
598
+ div_classes = ['itemized-list', node.style, node.role].compact
599
+ ul_classes = [node.style, ((node.option? 'brief') ? 'brief' : nil)].compact
600
+ ul_class_attr = ul_classes.empty? ? '' : %( class="#{ul_classes * ' '}")
601
+ id_attribute = node.id ? %( id="#{node.id}") : ''
602
+ lines = [%(<div#{id_attribute} class="#{div_classes * ' '}">)]
603
+ lines << %(<h3 class="list-heading">#{node.title}</h3>) if node.title?
604
+ lines << %(<ul#{ul_class_attr}>)
605
+ node.items.each do |item|
606
+ lines << %(<li>
607
+ <span class="principal">#{item.text}</span>)
608
+ if item.blocks?
609
+ lines << item.content
610
+ complex = true unless item.blocks.size == 1 && ::Asciidoctor::List === item.blocks[0]
611
+ end
612
+ lines << '</li>'
613
+ end
614
+ if complex
615
+ div_classes << 'complex'
616
+ lines[0] = %(<div class="#{div_classes * ' '}">)
617
+ end
618
+ lines << '</ul>
619
+ </div>'
620
+ lines * LF
621
+ end
622
+
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'] ||= [])
633
+ epub_properties << 'svg' unless epub_properties.include? 'svg'
634
+ 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%")
651
+ end
652
+ end
653
+ =end
654
+ %(<figure#{id_attr} class="image#{prepend_space node.role}">
655
+ <div class="content">
656
+ <img src="#{node.image_uri node.attr('target')}" #{img_attrs * ' '}/>
657
+ </div>#{node.title? ? %(
658
+ <figcaption>#{node.captioned_title}</figcaption>) : ''}
659
+ </figure>)
660
+ end
661
+
662
+ def convert_inline_anchor node
663
+ target = node.target
664
+ 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}")
676
+ else
677
+ id_attr = %( id="xref--#{refdoc_id}--#{refdoc_refid}")
678
+ 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
+
709
+ if xreftext
710
+ text ||= xreftext
711
+ 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})
714
+ end
715
+ end
716
+ %(<a#{id_attr} href="#{target}" class="xref">#{text || "[#{refid}]"}</a>)
717
+ when :ref
718
+ %(<a id="#{target}"></a>)
719
+ when :link
720
+ %(<a href="#{target}" class="link">#{node.text}</a>)
721
+ when :bibref
722
+ if @xrefs_seen.include? target
723
+ %(<a id="#{target}" href="#xref-#{target}">[#{target}]</a>)
724
+ else
725
+ %(<a id="#{target}"></a>[#{target}])
726
+ end
727
+ end
728
+ end
729
+
730
+ def convert_inline_break node
731
+ %(#{node.text}<br/>)
732
+ end
733
+
734
+ def convert_inline_button node
735
+ %(<b class="button">[<span class="label">#{node.text}</span>]</b>)
736
+ end
737
+
738
+ def convert_inline_callout node
739
+ num = CalloutStartNum
740
+ int_num = node.text.to_i
741
+ (int_num - 1).times { num = num.next }
742
+ %(<i class="conum" data-value="#{int_num}">#{num}</i>)
743
+ end
744
+
745
+ def convert_inline_footnote node
746
+ if (index = node.attr 'index')
747
+ %(<sup class="noteref">[<a id="noteref-#{index}" href="#note-#{index}" epub:type="noteref">#{index}</a>]</sup>)
748
+ elsif node.type == :xref
749
+ %(<mark class="noteref" title="Unresolved note reference">#{node.text}</mark>)
750
+ end
751
+ end
752
+
753
+ def convert_inline_image node
754
+ if node.type == 'icon'
755
+ @icon_names << (icon_name = node.target)
756
+ i_classes = ['icon', %(i-#{icon_name})]
757
+ i_classes << %(icon-#{node.attr 'size'}) if node.attr? 'size'
758
+ i_classes << %(icon-flip-#{(node.attr 'flip')[0]}) if node.attr? 'flip'
759
+ i_classes << %(icon-rotate-#{node.attr 'rotate'}) if node.attr? 'rotate'
760
+ i_classes << node.role if node.role?
761
+ %(<i class="#{i_classes * ' '}"></i>)
762
+ else
763
+ 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 * ' '}/>)
774
+ end
775
+ end
776
+
777
+ def convert_inline_indexterm node
778
+ node.type == :visible ? node.text : ''
779
+ end
780
+
781
+ def convert_inline_kbd node
782
+ if (keys = node.attr 'keys').size == 1
783
+ %(<kbd>#{keys[0]}</kbd>)
784
+ else
785
+ key_combo = keys.map {|key| %(<kbd>#{key}</kbd>) }.join '+'
786
+ %(<span class="keyseq">#{key_combo}</span>)
787
+ end
788
+ end
789
+
790
+ def convert_inline_menu node
791
+ menu = node.attr 'menu'
792
+ # NOTE we swap right angle quote with chevron right from FontAwesome using CSS
793
+ caret = %(#{NoBreakSpace}<span class="caret">#{RightAngleQuote}</span> )
794
+ if !(submenus = node.attr 'submenus').empty?
795
+ submenu_path = submenus.map {|submenu| %(<span class="submenu">#{submenu}</span>#{caret}) }.join.chop
796
+ %(<span class="menuseq"><span class="menu">#{menu}</span>#{caret}#{submenu_path} <span class="menuitem">#{node.attr 'menuitem'}</span></span>)
797
+ elsif (menuitem = node.attr 'menuitem')
798
+ %(<span class="menuseq"><span class="menu">#{menu}</span>#{caret}<span class="menuitem">#{menuitem}</span></span>)
799
+ else
800
+ %(<span class="menu">#{menu}</span>)
801
+ end
802
+ end
803
+
804
+ 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
824
+ end
825
+ end
826
+
827
+ def output_content node
828
+ node.content_model == :simple ? %(<p>#{node.content}</p>) : node.content
829
+ end
830
+
831
+ # FIXME: merge into with xml_sanitize helper
832
+ def xml_sanitize value, target = :attribute
833
+ sanitized = (value.include? '<') ? value.gsub(XmlElementRx, '').strip.tr_s(' ', ' ') : value
834
+ if target == :plain && (sanitized.include? ';')
835
+ sanitized = sanitized.gsub(CharEntityRx) { [$1.to_i].pack 'U*' } if sanitized.include? '&#'
836
+ sanitized = sanitized.gsub FromHtmlSpecialCharsRx, FromHtmlSpecialCharsMap
837
+ elsif target == :attribute
838
+ sanitized = sanitized.gsub '"', '&quot;' if sanitized.include? '"'
839
+ end
840
+ sanitized
841
+ end
842
+
843
+ # TODO: make check for last content paragraph a feature of Asciidoctor
844
+ def mark_last_paragraph root
845
+ return unless (last_block = root.blocks[-1])
846
+ last_block = last_block.blocks[-1] while last_block.context == :section && last_block.blocks?
847
+ if last_block.context == :paragraph
848
+ last_block.attributes['role'] = last_block.role? ? %(#{last_block.role} last) : 'last'
849
+ end
850
+ nil
851
+ end
852
+
853
+ # Prepend a space to the value if it's non-nil, otherwise return empty string.
854
+ def prepend_space value
855
+ value ? %( #{value}) : ''
856
+ end
857
+ end
858
+
859
+ class DocumentIdGenerator
860
+ ReservedIds = %w(cover nav ncx)
861
+ 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}));/
862
+ if defined? __dir__
863
+ InvalidIdCharsRx = /[^\p{Word}]+/
864
+ LeadingDigitRx = /^\p{Nd}/
865
+ else
866
+ InvalidIdCharsRx = /[^[:word:]]+/
867
+ LeadingDigitRx = /^[[:digit:]]/
868
+ end
869
+ class << self
870
+ def generate_id doc, pre = nil, sep = nil
871
+ synthetic = false
872
+ unless (id = doc.id)
873
+ # NOTE we assume pre is a valid ID prefix and that pre and sep only contain valid ID chars
874
+ pre ||= '_'
875
+ sep = sep ? sep.chr : '_'
876
+ if doc.header?
877
+ id = doc.doctitle sanitize: true
878
+ id = id.gsub CharRefRx do
879
+ $1 ? ($1 == 'amp' ? 'and' : sep) : ((d = $2 ? $2.to_i : $3.hex) == 8217 ? '' : ([d].pack 'U*'))
880
+ end if id.include? '&'
881
+ id = id.downcase.gsub InvalidIdCharsRx, sep
882
+ if id.empty?
883
+ id, synthetic = nil, true
884
+ else
885
+ unless sep.empty?
886
+ if (id = id.tr_s sep, sep).end_with? sep
887
+ if id == sep
888
+ id, synthetic = nil, true
889
+ else
890
+ id = (id.start_with? sep) ? id[1..-2] : id.chop
891
+ end
892
+ elsif id.start_with? sep
893
+ id = id[1..-1]
894
+ end
895
+ end
896
+ unless synthetic
897
+ if pre.empty?
898
+ id = %(_#{id}) if LeadingDigitRx =~ id
899
+ elsif !(id.start_with? pre)
900
+ id = %(#{pre}#{id})
901
+ end
902
+ end
903
+ end
904
+ elsif (first_section = doc.first_section)
905
+ id = first_section.id
906
+ else
907
+ synthetic = true
908
+ end
909
+ id = %(#{pre}document#{sep}#{doc.object_id}) if synthetic
910
+ end
911
+ logger.error %(chapter uses a reserved ID: #{id}) if !synthetic && (ReservedIds.include? id)
912
+ id
913
+ end
914
+ end
915
+ end
916
+
917
+ require_relative 'packager'
918
+
919
+ Extensions.register do
920
+ if (document = @document).backend == 'epub3'
921
+ document.attributes['spine'] = ''
922
+ 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
929
+ end
930
+ case (ebook_format = document.attributes['ebook-format'])
931
+ when 'epub3', 'kf8'
932
+ # all good
933
+ when 'mobi'
934
+ ebook_format = document.attributes['ebook-format'] = 'kf8'
935
+ else
936
+ # QUESTION should we display a warning?
937
+ ebook_format = document.attributes['ebook-format'] = 'epub3'
938
+ end
939
+ document.attributes[%(ebook-format-#{ebook_format})] = ''
940
+ # Only fire SpineItemProcessor for top-level include directives
941
+ include_processor SpineItemProcessor.new(document)
942
+ treeprocessor do
943
+ process do |doc|
944
+ doc.id = DocumentIdGenerator.generate_id doc, (doc.attr 'idprefix'), (doc.attr 'idseparator')
945
+ nil
946
+ end
947
+ end
948
+ end
949
+ end
950
+ end
951
+ end