asciidoctor-epub3 1.0.0.alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.adoc +22 -0
  3. data/NOTICE.adoc +53 -0
  4. data/README.adoc +744 -0
  5. data/Rakefile +78 -0
  6. data/bin/adb-push-ebook +25 -0
  7. data/bin/asciidoctor-epub3 +15 -0
  8. data/data/fonts/assorted-icons.ttf +0 -0
  9. data/data/fonts/fontawesome-icons.ttf +0 -0
  10. data/data/fonts/mplus1mn-bold-ascii.ttf +0 -0
  11. data/data/fonts/mplus1mn-bolditalic-ascii.ttf +0 -0
  12. data/data/fonts/mplus1mn-italic-ascii.ttf +0 -0
  13. data/data/fonts/mplus1mn-regular-ascii-conums.ttf +0 -0
  14. data/data/fonts/mplus1p-bold-latin-cyrillic.ttf +0 -0
  15. data/data/fonts/mplus1p-bold-latin-ext.ttf +0 -0
  16. data/data/fonts/mplus1p-bold-latin.ttf +0 -0
  17. data/data/fonts/mplus1p-bold-multilingual.ttf +0 -0
  18. data/data/fonts/mplus1p-light-latin-cyrillic.ttf +0 -0
  19. data/data/fonts/mplus1p-light-latin-ext.ttf +0 -0
  20. data/data/fonts/mplus1p-light-latin.ttf +0 -0
  21. data/data/fonts/mplus1p-light-multilingual.ttf +0 -0
  22. data/data/fonts/mplus1p-regular-latin-cyrillic.ttf +0 -0
  23. data/data/fonts/mplus1p-regular-latin-ext.ttf +0 -0
  24. data/data/fonts/mplus1p-regular-latin.ttf +0 -0
  25. data/data/fonts/mplus1p-regular-multilingual.ttf +0 -0
  26. data/data/fonts/notoserif-bold-latin-cyrillic.ttf +0 -0
  27. data/data/fonts/notoserif-bold-latin-ext.ttf +0 -0
  28. data/data/fonts/notoserif-bold-latin.ttf +0 -0
  29. data/data/fonts/notoserif-bold-multilingual.ttf +0 -0
  30. data/data/fonts/notoserif-bolditalic-latin-cyrillic.ttf +0 -0
  31. data/data/fonts/notoserif-bolditalic-latin-ext.ttf +0 -0
  32. data/data/fonts/notoserif-bolditalic-latin.ttf +0 -0
  33. data/data/fonts/notoserif-bolditalic-multilingual.ttf +0 -0
  34. data/data/fonts/notoserif-italic-latin-cyrillic.ttf +0 -0
  35. data/data/fonts/notoserif-italic-latin-ext.ttf +0 -0
  36. data/data/fonts/notoserif-italic-latin.ttf +0 -0
  37. data/data/fonts/notoserif-italic-multilingual.ttf +0 -0
  38. data/data/fonts/notoserif-regular-latin-cyrillic.ttf +0 -0
  39. data/data/fonts/notoserif-regular-latin-ext.ttf +0 -0
  40. data/data/fonts/notoserif-regular-latin.ttf +0 -0
  41. data/data/fonts/notoserif-regular-multilingual.ttf +0 -0
  42. data/data/images/default-avatar.jpg +0 -0
  43. data/data/images/default-avatar.png +0 -0
  44. data/data/images/default-avatar.svg +67 -0
  45. data/data/images/default-cover-large.png +0 -0
  46. data/data/images/default-cover.png +0 -0
  47. data/data/images/default-cover.svg +53 -0
  48. data/data/images/default-headshot.jpg +0 -0
  49. data/data/images/default-headshot.png +0 -0
  50. data/data/samples/asciidoctor-epub3-readme.adoc +744 -0
  51. data/data/samples/asciidoctor-js-extension.adoc +46 -0
  52. data/data/samples/asciidoctor-js-introduction.adoc +91 -0
  53. data/data/samples/i18n.adoc +161 -0
  54. data/data/samples/images/asciidoctor-js-chrome-extension.png +0 -0
  55. data/data/samples/images/avatars/graphitefriction.png +0 -0
  56. data/data/samples/images/avatars/mogztter.png +0 -0
  57. data/data/samples/images/avatars/mojavelinux.png +0 -0
  58. data/data/samples/images/correct-text-justification.png +0 -0
  59. data/data/samples/images/incorrect-text-justification.png +0 -0
  60. data/data/samples/images/screenshots/chapter-title-day.png +0 -0
  61. data/data/samples/images/screenshots/chapter-title.png +0 -0
  62. data/data/samples/images/screenshots/figure-admonition.png +0 -0
  63. data/data/samples/images/screenshots/section-title-paragraph.png +0 -0
  64. data/data/samples/images/screenshots/sidebar.png +0 -0
  65. data/data/samples/images/screenshots/table.png +0 -0
  66. data/data/samples/images/screenshots/text.png +0 -0
  67. data/data/samples/sample-book.adoc +20 -0
  68. data/data/samples/sample-content.adoc +168 -0
  69. data/data/styles/color-palette.css +28 -0
  70. data/data/styles/epub3-css3-only.css +161 -0
  71. data/data/styles/epub3-fonts.css +94 -0
  72. data/data/styles/epub3.css +1293 -0
  73. data/lib/asciidoctor-epub3.rb +5 -0
  74. data/lib/asciidoctor-epub3/converter.rb +859 -0
  75. data/lib/asciidoctor-epub3/core_ext/string.rb +7 -0
  76. data/lib/asciidoctor-epub3/font_icon_map.rb +376 -0
  77. data/lib/asciidoctor-epub3/packager.rb +466 -0
  78. data/lib/asciidoctor-epub3/spine_item_processor.rb +72 -0
  79. data/lib/asciidoctor-epub3/version.rb +5 -0
  80. data/scripts/generate-font-subsets.pe +225 -0
  81. metadata +192 -0
@@ -0,0 +1,5 @@
1
+ require 'asciidoctor'
2
+ require 'asciidoctor/extensions'
3
+ require 'gepub'
4
+ require_relative 'asciidoctor-epub3/converter'
5
+ require_relative 'asciidoctor-epub3/packager'
@@ -0,0 +1,859 @@
1
+ # encoding: UTF-8
2
+ require_relative 'spine_item_processor'
3
+ require_relative 'font_icon_map'
4
+
5
+ module Asciidoctor
6
+ module Epub3
7
+ #WordJoiner = [8288].pack 'U*'
8
+ WordJoiner = [65279].pack 'U*'
9
+
10
+ # Public: The main converter for the epub3 backend that handles packaging the
11
+ # EPUB3 or KF8 publication file.
12
+ class Converter
13
+ include ::Asciidoctor::Converter
14
+ include ::Asciidoctor::Writer
15
+
16
+ register_for 'epub3'
17
+
18
+ def initialize backend, opts
19
+ super
20
+ basebackend 'html'
21
+ outfilesuffix '.epub' # dummy outfilesuffix since it may be .mobi
22
+ htmlsyntax 'xml'
23
+ @validate = false
24
+ @extract = false
25
+ end
26
+
27
+ def convert spine_doc, name = nil
28
+ @validate = true if spine_doc.attr? 'ebook-validate'
29
+ @extract = true if spine_doc.attr? 'ebook-extract'
30
+ Packager.new spine_doc, (spine_doc.references[:spine_items] || [spine_doc]), spine_doc.attributes['ebook-format'].to_sym
31
+ end
32
+
33
+ # FIXME we have to package in write because we don't have access to target before this point
34
+ def write packager, target
35
+ # NOTE we use dirname of target since filename is calculated automatically
36
+ packager.package validate: @validate, extract: @extract, to_dir: (::File.dirname target)
37
+ nil
38
+ end
39
+ end
40
+
41
+ # Public: The converter for the epub3 backend that converts the individual
42
+ # content documents in an EPUB3 publication.
43
+ class ContentConverter
44
+ include ::Asciidoctor::Converter
45
+
46
+ register_for 'epub3-xhtml5'
47
+
48
+ WordJoiner = Epub3::WordJoiner
49
+ EOL = "\n"
50
+ NoBreakSpace = ' '
51
+ ThinNoBreakSpace = ' '
52
+ RightAngleQuote = '›'
53
+
54
+ XmlElementRx = /<\/?.+?>/
55
+ CharEntityRx = /&#(\d{2,5});/
56
+ NamedEntityRx = /&([A-Z]+);/
57
+ UppercaseTagRx = /<(\/)?([A-Z]+)>/
58
+
59
+ FromHtmlSpecialCharsMap = {
60
+ '&lt;' => '<',
61
+ '&gt;' => '>',
62
+ '&amp;' => '&'
63
+ }
64
+
65
+ FromHtmlSpecialCharsRx = /(?:#{FromHtmlSpecialCharsMap.keys * '|'})/
66
+
67
+ ToHtmlSpecialCharsMap = {
68
+ '&' => '&amp;',
69
+ '<' => '&lt;',
70
+ '>' => '&gt;'
71
+ }
72
+
73
+ ToHtmlSpecialCharsRx = /[#{ToHtmlSpecialCharsMap.keys.join}]/
74
+
75
+ OpenParagraphTagRx = /^<p>/
76
+ CloseParagraphTagRx = /<\/p>$/
77
+
78
+ def initialize backend, opts
79
+ super
80
+ basebackend 'html'
81
+ outfilesuffix '.xhtml'
82
+ htmlsyntax 'xml'
83
+ @xrefs_used = ::Set.new
84
+ @icon_names = []
85
+ end
86
+
87
+ def convert node, name = nil
88
+ if respond_to?(name ||= node.node_name)
89
+ send name, node
90
+ else
91
+ warn %(conversion missing in epub3 backend for #{name})
92
+ end
93
+ end
94
+
95
+ # TODO aggregate authors of spine document into authors attribute(s) on main document
96
+ def navigation_document node, spine
97
+ doctitle_sanitized = ((node.doctitle sanitize: true) || (node.attr 'untitled-label')).gsub WordJoiner, ''
98
+ lines = [%(<!DOCTYPE html>
99
+ <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}">
100
+ <head>
101
+ <meta charset="UTF-8"/>
102
+ <title>#{doctitle_sanitized}</title>
103
+ <link rel="stylesheet" type="text/css" href="styles/epub3.css"/>
104
+ <link rel="stylesheet" type="text/css" href="styles/epub3-css3-only.css" media="(min-device-width: 0px)"/>
105
+ </head>
106
+ <body>
107
+ <h1>#{doctitle_sanitized}</h1>
108
+ <nav epub:type="toc" id="toc">
109
+ <h2>#{node.attr 'toc-title'}</h2>
110
+ <ol>)]
111
+ spine.each do |item|
112
+ lines << %(<li><a href="#{item.id || (item.attr 'docname')}.xhtml">#{((item.doctitle sanitize: true) || (item.attr 'untitled-label')).gsub WordJoiner, ''}</a></li>)
113
+ end
114
+ lines << %(</ol>
115
+ </nav>
116
+ </body>
117
+ </html>)
118
+ lines * EOL
119
+ end
120
+
121
+ def document node
122
+ docid = node.id
123
+ if (doctitle = node.doctitle)
124
+ doctitle_sanitized = (node.doctitle sanitize: :sgml).gsub WordJoiner, ''
125
+ if doctitle.include? ': '
126
+ title, _, subtitle = doctitle.rpartition ': '
127
+ else
128
+ # HACK until we get proper handling of title-only in CSS
129
+ title = ''
130
+ subtitle = doctitle
131
+ end
132
+ else
133
+ # HACK until we get proper handling of title-only in CSS
134
+ title = ''
135
+ subtitle = node.attr 'untitled-label'
136
+ end
137
+ subtitle_formatted = subtitle.gsub(WordJoiner, '').split(' ').map {|w| %(<b>#{w}</b>) } * ' '
138
+
139
+ title_upper = title.upcase
140
+ # FIXME make this uppercase routine more intelligent, less fragile
141
+ subtitle_formatted_upper = subtitle_formatted.upcase
142
+ .gsub(UppercaseTagRx) { %(<#{$1}#{$2.downcase}>) }
143
+ .gsub(NamedEntityRx) { %(&#{$1.downcase};) }
144
+
145
+ author = node.attr 'author'
146
+ username = node.attr 'username', 'default'
147
+ # FIXME needs to resolve to the imagesdir of the spine document, not this document
148
+ #imagesdir = (node.attr 'imagesdir', '.').chomp '/'
149
+ #imagesdir = (imagesdir == '.' ? nil : %(#{imagesdir}/))
150
+ imagesdir = 'images/'
151
+
152
+ mark_last_paragraph node
153
+ content = node.content
154
+
155
+ # NOTE must run after content is resolved
156
+ # NOTE pubtree requires icon CSS to be repeated inside <body> (or in a linked stylesheet); perhaps create dynamic CSS file?
157
+ icon_css = unless @icon_names.empty?
158
+ icon_defs = @icon_names.map {|name|
159
+ %(.i-#{name}::before { content: "#{FontIconMap[name.tr('-', '_').to_sym]}"; })
160
+ } * EOL
161
+ %(<style>
162
+ #{icon_defs}
163
+ </style>
164
+ )
165
+ end
166
+
167
+ # NOTE kindlegen seems to mangle the <header> element, so we wrap its content in a div
168
+ lines = [%(<!DOCTYPE html>
169
+ <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}">
170
+ <head>
171
+ <meta charset="UTF-8"/>
172
+ <title>#{doctitle_sanitized}</title>
173
+ <link rel="stylesheet" type="text/css" href="styles/epub3.css"/>
174
+ <link rel="stylesheet" type="text/css" href="styles/epub3-css3-only.css" media="(min-device-width: 0px)"/>
175
+ #{icon_css}<script type="text/javascript">
176
+ document.addEventListener('DOMContentLoaded', function(event) {
177
+ var epubReader = navigator.epubReadingSystem;
178
+ if (!epubReader) {
179
+ if (window.parent == window || !(epubReader = window.parent.navigator.epubReadingSystem)) {
180
+ return;
181
+ }
182
+ }
183
+ document.body.setAttribute('class', epubReader.name.toLowerCase().replace(/ /g, '-'));
184
+ });
185
+ </script>
186
+ </head>
187
+ <body>
188
+ <section class="chapter" title="#{doctitle_sanitized.gsub '"', '&quot;'}" epub:type="chapter" id="#{docid}">
189
+ #{icon_css && (icon_css.sub '<style>', '<style scoped="scoped">')}<header>
190
+ <div class="chapter-header">
191
+ <p class="byline"><img src="#{imagesdir}avatars/#{username}.jpg"/> <b class="author">#{author}</b></p>
192
+ <h1 class="chapter-title">#{title_upper}#{subtitle ? %[ <small class="subtitle">#{subtitle_formatted_upper}</small>] : nil}</h1>
193
+ </div>
194
+ </header>
195
+ #{content})]
196
+
197
+ if node.footnotes?
198
+ # NOTE kindlegen seems to mangle the <footer> element, so we wrap its content in a div
199
+ lines << '<footer>
200
+ <div class="chapter-footer">
201
+ <div class="footnotes">'
202
+ node.footnotes.each do |footnote|
203
+ lines << %(<aside id="note-#{footnote.index}" epub:type="footnote">
204
+ <p><sup class="noteref"><a href="#noteref-#{footnote.index}">#{footnote.index}</a></sup> #{footnote.text}</p>
205
+ </aside>)
206
+ end
207
+ lines << '</div>
208
+ </div>
209
+ </footer>'
210
+ end
211
+
212
+ lines << '</section>
213
+ </body>
214
+ </html>'
215
+
216
+ lines * EOL
217
+ end
218
+
219
+ def section node
220
+ hlevel = node.level + 1
221
+ epub_type_attr = node.special ? %( epub:type="#{node.sectname}") : nil
222
+ div_classes = [%(sect#{node.level}), node.role].compact
223
+ title = node.title
224
+ title_sanitized = xml_sanitize title
225
+ if node.document.header? || node.level != 1 || node != node.document.first_section
226
+ %(<section class="#{div_classes * ' '}" title="#{title_sanitized}"#{epub_type_attr}>
227
+ <h#{hlevel} id="#{node.id}">#{title}</h#{hlevel}>#{(content = node.content).empty? ? nil : %[
228
+ #{content}]}
229
+ </section>)
230
+ else
231
+ # document has no level-0 heading and this heading serves as the document title
232
+ node.content
233
+ end
234
+ end
235
+
236
+ # TODO support use of quote block as abstract
237
+ def preamble node
238
+ if (first_block = node.blocks[0]) && first_block.style == 'abstract'
239
+ abstract first_block
240
+ # REVIEW should we treat the preamble as an abstract in general?
241
+ elsif first_block && node.blocks.size == 1
242
+ abstract first_block
243
+ else
244
+ node.content
245
+ end
246
+ end
247
+
248
+ # QUESTION use convert_content?
249
+ def open node
250
+ node.content
251
+ end
252
+
253
+ def abstract node
254
+ %(<div class="abstract" epub:type="preamble">
255
+ #{convert_content node}
256
+ </div>)
257
+ end
258
+
259
+ def paragraph node
260
+ role = node.role
261
+ # stack-head is the alternative to the default, inline-head (where inline means "run-in")
262
+ head_stop = node.attr 'head-stop', (role && (node.has_role? 'stack-head') ? nil : '.')
263
+ head = node.title? ? %(<strong class="head">#{title = node.title}#{head_stop && title !~ /[[:punct:]]$/ ? head_stop : nil}</strong> ) : nil
264
+ if role
265
+ if node.has_role? 'signature'
266
+ node.set_option 'hardbreaks'
267
+ end
268
+ %(<p class="#{role}">#{head}#{node.content}</p>)
269
+ else
270
+ %(<p>#{head}#{node.content}</p>)
271
+ end
272
+ end
273
+
274
+ def pass node
275
+ content = node.content
276
+ if content == '<?hard-pagebreak?>'
277
+ '<hr epub:type="pagebreak" class="pagebreak"/>'
278
+ else
279
+ content
280
+ end
281
+ end
282
+
283
+ def admonition node
284
+ if node.title?
285
+ title = node.title
286
+ title_sanitized = xml_sanitize title
287
+ title_attr = %( title="#{node.caption}: #{title_sanitized}")
288
+ title_el = %(<h2>#{title}</h2>
289
+ )
290
+ else
291
+ title_attr = %( title="#{node.caption}")
292
+ title_el = nil
293
+ end
294
+
295
+ type = node.attr 'name'
296
+ epub_type = case type
297
+ when 'tip'
298
+ 'help'
299
+ when 'note'
300
+ 'note'
301
+ when 'important', 'warning', 'caution'
302
+ 'warning'
303
+ end
304
+ %(<aside class="admonition #{type}"#{title_attr} epub:type="#{epub_type}">
305
+ #{title_el}<div class="content">
306
+ #{convert_content node}
307
+ </div>
308
+ </aside>)
309
+ end
310
+
311
+ def example node
312
+ title_div = node.title? ? %(<div class="example-title">#{node.title}</div>
313
+ ) : nil
314
+ %(<div class="example">
315
+ #{title_div}<div class="example-content">
316
+ #{convert_content node}
317
+ </div>
318
+ </div>)
319
+ end
320
+
321
+ def listing node
322
+ figure_classes = ['listing']
323
+ figure_classes << 'coalesce' if node.option? 'unbreakable'
324
+ pre_classes = if node.style == 'source'
325
+ ['source', %(language-#{node.attr 'language'})]
326
+ else
327
+ ['screen']
328
+ end
329
+ title_div = node.title? ? %(<figcaption>#{node.captioned_title}</figcaption>
330
+ ) : nil
331
+ # patches conums to fix extra or missing leading space
332
+ # TODO apply this patch upstream to Asciidoctor
333
+ %(<figure class="#{figure_classes * ' '}">
334
+ #{title_div}<pre class="#{pre_classes * ' '}"><code>#{node.content.gsub(/(?<! )<i class="conum"| +<i class="conum"/, ' <i class="conum"')}</code></pre>
335
+ </figure>)
336
+ end
337
+
338
+ # QUESTION should we wrap the <pre> in either <div> or <figure>?
339
+ def literal node
340
+ %(<pre class="screen">#{node.content}</pre>)
341
+ end
342
+
343
+ def page_break node
344
+ '<hr epub:type="pagebreak" class="pagebreak"/>'
345
+ end
346
+
347
+ def thematic_break node
348
+ '<hr class="thematicbreak"/>'
349
+ end
350
+
351
+ def quote node
352
+ footer_content = []
353
+ if attribution = (node.attr 'attribution')
354
+ footer_content << attribution
355
+ end
356
+
357
+ if citetitle = (node.attr 'citetitle')
358
+ citetitle_sanitized = xml_sanitize citetitle
359
+ footer_content << %(<cite title="#{citetitle_sanitized}">#{citetitle}</cite>)
360
+ end
361
+
362
+ if node.title?
363
+ footer_content << %(<span class="context">#{node.title}</span>)
364
+ end
365
+
366
+ footer_tag = footer_content.empty? ? nil : %(
367
+ <footer>~ #{footer_content * ' '}</footer>)
368
+ content = (convert_content node).strip.
369
+ sub(OpenParagraphTagRx, '<p><span class="open-quote">“</span>').
370
+ sub(CloseParagraphTagRx, '<span class="close-quote">”</span></p>')
371
+ %(<div class="blockquote">
372
+ <blockquote>
373
+ #{content}#{footer_tag}
374
+ </blockquote>
375
+ </div>)
376
+ end
377
+
378
+ def verse node
379
+ footer_content = []
380
+ if attribution = (node.attr 'attribution')
381
+ footer_content << attribution
382
+ end
383
+
384
+ if citetitle = (node.attr 'citetitle')
385
+ citetitle_sanitized = xml_sanitize citetitle
386
+ footer_content << %(<cite title="#{citetitle_sanitized}">#{citetitle}</cite>)
387
+ end
388
+
389
+ footer_tag = footer_content.size > 0 ? %(
390
+ <span class="attribution">~ #{footer_content * ', '}</span>) : nil
391
+ %(<div class="verse">
392
+ <pre>#{node.content}#{footer_tag}</pre>
393
+ </div>)
394
+ end
395
+
396
+ def sidebar node
397
+ classes = ['sidebar']
398
+ if node.title?
399
+ classes << 'titled'
400
+ title = node.title
401
+ title_sanitized = xml_sanitize title
402
+ title_attr = %( title="#{title_sanitized}")
403
+ title_upper = title.upcase.gsub(NamedEntityRx) { %(&#{$1.downcase};) }
404
+ title_el = %(<h2>#{title_upper}</h2>
405
+ )
406
+ else
407
+ title_attr = nil
408
+ title_el = nil
409
+ end
410
+
411
+ %(<aside class="#{classes * ' '}"#{title_attr} epub:type="sidebar">
412
+ #{title_el}<div class="content">
413
+ #{convert_content node}
414
+ </div>
415
+ </aside>)
416
+ end
417
+
418
+ def table node
419
+ lines = [%(<div class="table">)]
420
+ lines << %(<div class="content">)
421
+ table_id_attr = node.id ? %( id="#{node.id}") : nil
422
+ frame_class = {
423
+ 'all' => 'table-framed',
424
+ 'topbot' => 'table-framed-topbot',
425
+ 'sides' => 'table-framed-sides'
426
+ }
427
+ grid_class = {
428
+ 'all' => 'table-grid',
429
+ 'rows' => 'table-grid-rows',
430
+ 'cols' => 'table-grid-cols'
431
+ }
432
+ table_classes = %W(table #{frame_class[(node.attr 'frame')] || frame_class['topbot']} #{grid_class[(node.attr 'grid')] || grid_class['rows']})
433
+ if (role = node.role)
434
+ table_classes << role
435
+ end
436
+ table_class_attr = %( class="#{table_classes * ' '}")
437
+ table_styles = []
438
+ unless node.option? 'autowidth'
439
+ table_styles << %(width: #{node.attr 'tablepcwidth'}%;)
440
+ end
441
+ table_style_attr = table_styles.size > 0 ? %( style="#{table_styles * ' '}") : nil
442
+
443
+ lines << %(<table#{table_id_attr}#{table_class_attr}#{table_style_attr}>)
444
+ lines << %(<caption>#{node.captioned_title}</caption>) if node.title?
445
+ if (node.attr 'rowcount') > 0
446
+ lines << '<colgroup>'
447
+ #if node.option? 'autowidth'
448
+ tag = %(<col/>)
449
+ node.columns.size.times do
450
+ lines << tag
451
+ end
452
+ #else
453
+ # node.columns.each do |col|
454
+ # lines << %(<col style="width: #{col.attr 'colpcwidth'}%;"/>)
455
+ # end
456
+ #end
457
+ lines << '</colgroup>'
458
+ [:head, :foot, :body].select {|tsec| !node.rows[tsec].empty? }.each do |tsec|
459
+ lines << %(<t#{tsec}>)
460
+ node.rows[tsec].each do |row|
461
+ lines << '<tr>'
462
+ row.each do |cell|
463
+ if tsec == :head
464
+ cell_content = cell.text
465
+ else
466
+ case cell.style
467
+ when :asciidoc
468
+ cell_content = %(<div>#{cell.content}</div>)
469
+ when :verse
470
+ cell_content = %(<div class="verse">#{cell.text}</div>)
471
+ when :literal
472
+ cell_content = %(<div class="literal"><pre>#{cell.text}</pre></div>)
473
+ else
474
+ cell_content = ''
475
+ cell.content.each do |text|
476
+ cell_content = %(#{cell_content}<p>#{text}</p>)
477
+ end
478
+ end
479
+ end
480
+
481
+ cell_tag_name = (tsec == :head || cell.style == :header ? 'th' : 'td')
482
+ cell_classes = []
483
+ if (halign = cell.attr 'halign') && halign != 'left'
484
+ cell_classes << 'halign-left'
485
+ end
486
+ if (halign = cell.attr 'valign') && halign != 'top'
487
+ cell_classes << 'valign-top'
488
+ end
489
+ cell_class_attr = cell_classes.size > 0 ? %( class="#{cell_classes * ' '}") : nil
490
+ cell_colspan_attr = cell.colspan ? %( colspan="#{cell.colspan}") : nil
491
+ cell_rowspan_attr = cell.rowspan ? %( rowspan="#{cell.rowspan}") : nil
492
+ cell_style_attr = (node.document.attr? 'cellbgcolor') ? %( style="background-color: #{node.document.attr 'cellbgcolor'};") : nil
493
+ lines << %(<#{cell_tag_name}#{cell_class_attr}#{cell_colspan_attr}#{cell_rowspan_attr}#{cell_style_attr}>#{cell_content}</#{cell_tag_name}>)
494
+ end
495
+ lines << '</tr>'
496
+ end
497
+ lines << %(</t#{tsec}>)
498
+ end
499
+ end
500
+ lines << '</table>
501
+ </div>
502
+ </div>'
503
+ lines * EOL
504
+ end
505
+
506
+ def colist node
507
+ lines = ['<div class="callout-list">
508
+ <ol>']
509
+ num = "\u2460"
510
+ node.items.each_with_index do |item, i|
511
+ lines << %(<li><i class="conum" data-value="#{i + 1}">#{num}</i> #{item.text}</li>)
512
+ num = num.next
513
+ end
514
+ lines << '</ol>
515
+ </div>'
516
+ end
517
+
518
+ # TODO add complex class if list has nested blocks
519
+ def dlist node
520
+ lines = []
521
+ case (style = node.style)
522
+ when 'itemized', 'ordered'
523
+ list_tag_name = (style == 'itemized' ? 'ul' : 'ol')
524
+ role = node.role
525
+ subject_stop = node.attr 'subject-stop', (role && (node.has_role? 'stack') ? nil : ':')
526
+ # QUESTION should we just use itemized-list and ordered-list as the class here? or just list?
527
+ div_classes = [%(#{style}-list), role].compact
528
+ list_class_attr = (node.option? 'brief') ? ' class="brief"' : nil
529
+ lines << %(<div class="#{div_classes * ' '}">
530
+ <#{list_tag_name}#{list_class_attr}#{list_tag_name == 'ol' && (node.option? 'reversed') ? ' reversed="reversed"' : nil}>)
531
+ node.items.each do |subjects, dd|
532
+ # consists of one term (a subject) and supporting content
533
+ subject = [*subjects].first.text
534
+ subject_plain = xml_sanitize subject, :plain
535
+ subject_element = %(<strong class="subject">#{subject}#{subject_stop && subject_plain !~ /[[:punct:]]$/ ? subject_stop : nil}</strong>)
536
+ lines << '<li>'
537
+ if dd
538
+ # NOTE: must wrap remaining text in a span to help webkit justify the text properly
539
+ lines << %(<span class="principal">#{subject_element}#{dd.text? ? %[ <span class="supporting">#{dd.text}</span>] : nil}</span>)
540
+ lines << dd.content if dd.blocks?
541
+ else
542
+ lines << %(<span class="principal">#{subject_element}</span>)
543
+ end
544
+ lines << '</li>'
545
+ end
546
+ lines << %(</#{list_tag_name}>
547
+ </div>)
548
+ else
549
+ lines << '<div class="description-list">
550
+ <dl>'
551
+ node.items.each do |terms, dd|
552
+ [*terms].each do |dt|
553
+ lines << %(<dt>
554
+ <span class="term">#{dt.text}</span>
555
+ </dt>)
556
+ end
557
+ if dd
558
+ lines << '<dd>'
559
+ if dd.blocks?
560
+ lines << %(<span class="principal">#{dd.text}</span>) if dd.text?
561
+ lines << dd.content
562
+ else
563
+ lines << dd.text
564
+ end
565
+ lines << '</dd>'
566
+ end
567
+ end
568
+ lines << '</dl>
569
+ </div>'
570
+ end
571
+ lines * EOL
572
+ end
573
+
574
+ # TODO support start attribute
575
+ def olist node
576
+ complex = false
577
+ div_classes = ['ordered-list', node.style, node.role].compact
578
+ ol_classes = [node.style, ((node.option? 'brief') ? 'brief' : nil)].compact
579
+ ol_class_attr = ol_classes.empty? ? nil : %( class="#{ol_classes * ' '}")
580
+ id_attribute = node.id ? %( id="#{node.id}") : nil
581
+ lines = [%(<div#{id_attribute} class="#{div_classes * ' '}">)]
582
+ lines << %(<h3>#{node.title}</h3>) if node.title?
583
+ lines << %(<ol#{ol_class_attr}#{(node.option? 'reversed') ? ' reversed="reversed"' : nil}>)
584
+ node.items.each do |item|
585
+ lines << %(<li>
586
+ <span class="principal">#{item.text}</span>)
587
+ if item.blocks?
588
+ lines << item.content
589
+ complex = true unless item.blocks.size == 1 && ::Asciidoctor::List === item.blocks[0]
590
+ end
591
+ lines << '</li>'
592
+ end
593
+ if complex
594
+ div_classes << 'complex'
595
+ lines[0] = %(<div class="#{div_classes * ' '}">)
596
+ end
597
+ lines << '</ol>
598
+ </div>'
599
+ lines * EOL
600
+ end
601
+
602
+ def ulist node
603
+ complex = false
604
+ div_classes = ['itemized-list', node.style, node.role].compact
605
+ # TODO could strip WordJoiner if brief since not using justify
606
+ ul_classes = [node.style, ((node.option? 'brief') ? 'brief' : nil)].compact
607
+ ul_class_attr = ul_classes.empty? ? nil : %( class="#{ul_classes * ' '}")
608
+ id_attribute = node.id ? %( id="#{node.id}") : nil
609
+ lines = [%(<div#{id_attribute} class="#{div_classes * ' '}">)]
610
+ lines << %(<h3>#{node.title}</h3>) if node.title?
611
+ lines << %(<ul#{ul_class_attr}>)
612
+ node.items.each do |item|
613
+ lines << %(<li>
614
+ <span class="principal">#{item.text}</span>)
615
+ if item.blocks?
616
+ lines << item.content
617
+ complex = true unless item.blocks.size == 1 && ::Asciidoctor::List === item.blocks[0]
618
+ end
619
+ lines << '</li>'
620
+ end
621
+ if complex
622
+ div_classes << 'complex'
623
+ lines[0] = %(<div class="#{div_classes * ' '}">)
624
+ end
625
+ lines << '</ul>
626
+ </div>'
627
+ lines * EOL
628
+ end
629
+
630
+ def image node
631
+ target = node.attr 'target'
632
+ type = (::File.extname target)[1..-1]
633
+ img_attrs = [%(alt="#{node.attr 'alt'}")]
634
+ case type
635
+ when 'svg'
636
+ img_attrs << %(style="width: #{node.attr 'scaledwidth', '100%'};")
637
+ # TODO make this a convenience method on document
638
+ epub_properties = (node.document.attr 'epub-properties') || []
639
+ unless epub_properties.include? 'svg'
640
+ epub_properties << 'svg'
641
+ node.document.attributes['epub-properties'] = epub_properties
642
+ end
643
+ else
644
+ if node.attr? 'scaledwidth'
645
+ img_attrs << %(style="width: #{node.attr 'scaledwidth'};")
646
+ end
647
+ end
648
+ =begin
649
+ # NOTE to set actual width and height, use CSS width and height
650
+ if type == 'svg'
651
+ if node.attr? 'scaledwidth'
652
+ img_attrs << %(width="#{node.attr 'scaledwidth'}")
653
+ # Kindle
654
+ #elsif node.attr? 'scaledheight'
655
+ # img_attrs << %(width="#{node.attr 'scaledheight'}" height="#{node.attr 'scaledheight'}")
656
+ # ePub3
657
+ elsif node.attr? 'scaledheight'
658
+ img_attrs << %(height="#{node.attr 'scaledheight'}" style="max-height: #{node.attr 'scaledheight'} !important;")
659
+ else
660
+ # Aldiko doesn't not scale width to 100% by default
661
+ img_attrs << %(width="100%")
662
+ end
663
+ end
664
+ =end
665
+ %(<figure class="image">
666
+ <div class="content">
667
+ <img src="#{node.image_uri node.attr('target')}" #{img_attrs * ' '}/>
668
+ </div>#{node.title? ? %[
669
+ <figcaption>#{node.captioned_title}</figcaption>] : nil}
670
+ </figure>)
671
+ end
672
+
673
+ def inline_anchor node
674
+ target = node.target
675
+ case node.type
676
+ when :xref
677
+ refid = (node.attr 'refid') || target
678
+ id_attr = unless @xrefs_used.include? refid
679
+ @xrefs_used << refid
680
+ %( id="xref-#{refid}")
681
+ end
682
+ # FIXME seems like text should be prepared already
683
+ # FIXME would be nice to know what type the target is (e.g., bibref)
684
+ text = node.text || (node.document.references[:ids][refid] || %([#{refid}]))
685
+ %(<a#{id_attr} href="#{target}" class="xref">#{text}</a>#{WordJoiner})
686
+ when :ref
687
+ %(<a id="#{target}"></a>)
688
+ when :link
689
+ %(<a href="#{target}" class="link">#{node.text}</a>#{WordJoiner})
690
+ when :bibref
691
+ %(<a id="#{target}" href="#xref-#{target}">[#{target}]</a>#{WordJoiner})
692
+ end
693
+ end
694
+
695
+ def inline_break node
696
+ %(#{node.text}<br/>)
697
+ end
698
+
699
+ def inline_button node
700
+ %(<b class="button">[<span class="label">#{node.text}</span>]</b>#{WordJoiner})
701
+ end
702
+
703
+ def inline_callout node
704
+ num = "\u2460"
705
+ int_num = node.text.to_i
706
+ (int_num - 1).times { num = num.next }
707
+ %(<i class="conum" data-value="#{int_num}">#{num}</i>)
708
+ end
709
+
710
+ def inline_footnote node
711
+ if (index = node.attr 'index')
712
+ %(<sup class="noteref">[<a id="noteref-#{index}" href="#note-#{index}" epub:type="noteref">#{index}</a>]</sup>)
713
+ elsif node.type == :xref
714
+ %(<mark class="noteref" title="Unresolved note reference">#{node.text}</mark>)
715
+ end
716
+ end
717
+
718
+ def inline_image node
719
+ if (type = node.type) == 'icon'
720
+ @icon_names << (icon_name = node.target)
721
+ i_classes = ['icon', %(i-#{icon_name})]
722
+ i_classes << %(icon-#{node.attr 'size'}) if node.attr? 'size'
723
+ i_classes << %(icon-flip-#{(node.attr 'flip')[0]}) if node.attr? 'flip'
724
+ i_classes << %(icon-rotate-#{node.attr 'rotate'}) if node.attr? 'rotate'
725
+ i_classes << node.role if node.role?
726
+ %(<i class="#{i_classes * ' '}"></i>)
727
+ else
728
+ target = node.image_uri node.target
729
+ class_attr = %( class="#{node.role}") if node.role?
730
+ %(<img src="#{target}" alt="#{node.attr 'alt'}"#{class_attr}/>)
731
+ end
732
+ end
733
+
734
+ def inline_indexterm node
735
+ node.type == :visible ? node.text : ''
736
+ end
737
+
738
+ def inline_kbd node
739
+ if (keys = node.attr 'keys').size == 1
740
+ %(<kbd>#{keys[0]}</kbd>)
741
+ else
742
+ key_combo = keys.map {|key| %(<kbd>#{key}</kbd>+) }.join.chop
743
+ %(<span class="keyseq">#{key_combo}</span>)
744
+ end
745
+ end
746
+
747
+ def inline_menu node
748
+ menu = node.attr 'menu'
749
+ # NOTE we swap right angle quote with chevron right from FontAwesome using CSS
750
+ caret = %(#{NoBreakSpace}<span class="caret">#{RightAngleQuote}</span> )
751
+ if !(submenus = node.attr 'submenus').empty?
752
+ submenu_path = submenus.map {|submenu| %(<span class="submenu">#{submenu}</span>#{caret}) }.join.chop
753
+ %(<span class="menuseq"><span class="menu">#{menu}</span>#{caret}#{submenu_path} <span class="menuitem">#{node.attr 'menuitem'}</span></span>)
754
+ elsif (menuitem = node.attr 'menuitem')
755
+ %(<span class="menuseq"><span class="menu">#{menu}</span>#{caret}<span class="menuitem">#{menuitem}</span></span>)
756
+ else
757
+ %(<span class="menu">#{menu}</span>)
758
+ end
759
+ end
760
+
761
+ def inline_quoted node
762
+ case node.type
763
+ when :strong
764
+ %(<strong>#{node.text}</strong>#{WordJoiner})
765
+ when :emphasis
766
+ %(<em>#{node.text}</em>#{WordJoiner})
767
+ when :monospaced
768
+ %(<code class="literal">#{node.text}</code>#{WordJoiner})
769
+ when :double
770
+ #%(&#x201c;#{node.text}&#x201d;)
771
+ %(“#{node.text}”)
772
+ when :single
773
+ #%(&#x2018;#{node.text}&#x2019;)
774
+ %(‘#{node.text}’)
775
+ when :superscript
776
+ %(<sup>#{node.text}</sup>#{WordJoiner})
777
+ when :subscript
778
+ %(<sub>#{node.text}</sub>#{WordJoiner})
779
+ else
780
+ node.text
781
+ end
782
+ end
783
+
784
+ def convert_content node
785
+ if node.content_model == :simple
786
+ %(<p>#{node.content}</p>)
787
+ else
788
+ node.content
789
+ end
790
+ end
791
+
792
+ def xml_sanitize value, target = :attribute
793
+ sanitized = (value.include? '<') ? value.gsub(XmlElementRx, '').tr_s(' ', ' ').strip : value
794
+ if target == :plain && (sanitized.include? ';')
795
+ sanitized = sanitized.gsub(CharEntityRx) { [$1.to_i].pack('U*') }.gsub(FromHtmlSpecialCharsRx, FromHtmlSpecialCharsMap)
796
+ elsif target == :attribute
797
+ sanitized = sanitized.gsub(WordJoiner, '').gsub('"', '&quot;')
798
+ end
799
+ sanitized
800
+ end
801
+
802
+ # TODO make check for last content paragraph a feature of Asciidoctor
803
+ def mark_last_paragraph root
804
+ return unless (last_block = root.blocks[-1])
805
+ while last_block.context == :section && last_block.blocks?
806
+ last_block = last_block.blocks[-1]
807
+ end
808
+ if last_block.context == :paragraph
809
+ last_block.attributes['role'] = last_block.role? ? %(#{last_block.role} last) : 'last'
810
+ end
811
+ nil
812
+ end
813
+ end
814
+
815
+ class DocumentIdGenerator
816
+ class << self
817
+ def generate_id doc
818
+ unless (id = doc.id)
819
+ id = if doc.header?
820
+ doc.doctitle(sanitize: :sgml).gsub(WordJoiner, '').downcase.delete(':').tr_s(' ', '-').tr_s('-', '-')
821
+ elsif (first_section = doc.first_section)
822
+ first_section.id
823
+ else
824
+ %(document-#{doc.object_id})
825
+ end
826
+ end
827
+ id
828
+ end
829
+ end
830
+ end
831
+
832
+ require_relative 'packager'
833
+
834
+ Extensions.register do
835
+ if (document = @document).backend == 'epub3'
836
+ document.attributes['spine'] = ''
837
+ document.set_attribute 'listing-caption', 'Listing'
838
+ if !(defined? ::AsciidoctorJ) && (::Gem::try_activate 'pygments.rb')
839
+ if document.set_attribute 'source-highlighter', 'pygments'
840
+ document.set_attribute 'pygments-css', 'style'
841
+ document.set_attribute 'pygments-style', 'bw'
842
+ end
843
+ end
844
+ case (ebook_format = document.attributes['ebook-format'])
845
+ when 'epub3', 'kf8'
846
+ # all good
847
+ when 'mobi'
848
+ document.attributes['ebook-format'] = 'kf8'
849
+ else
850
+ document.attributes['ebook-format'] = 'epub3'
851
+ end
852
+ document.attributes[%(ebook-format-#{ebook_format})] = ''
853
+ # Only fire SpineItemProcessor for top-level include directives
854
+ include_processor SpineItemProcessor.new(document)
855
+ treeprocessor { process {|doc| doc.id = DocumentIdGenerator.generate_id doc } }
856
+ end
857
+ end
858
+ end
859
+ end