asciidoctor-pdf 1.5.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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.adoc +22 -0
  3. data/NOTICE.adoc +76 -0
  4. data/README.adoc +263 -0
  5. data/Rakefile +78 -0
  6. data/bin/asciidoctor-pdf +15 -0
  7. data/bin/optimize-pdf +63 -0
  8. data/data/fonts/LICENSE-liberation-fonts-2.00.1 +102 -0
  9. data/data/fonts/LICENSE-mplus-testflight-58 +16 -0
  10. data/data/fonts/LICENSE-noto-fonts-2014-01-30 +201 -0
  11. data/data/fonts/liberationmono-bold-latin.ttf +0 -0
  12. data/data/fonts/liberationmono-bolditalic-latin.ttf +0 -0
  13. data/data/fonts/liberationmono-italic-latin.ttf +0 -0
  14. data/data/fonts/liberationmono-regular-latin.ttf +0 -0
  15. data/data/fonts/mplus1mn-bold-ascii.ttf +0 -0
  16. data/data/fonts/mplus1mn-bolditalic-ascii.ttf +0 -0
  17. data/data/fonts/mplus1mn-italic-ascii.ttf +0 -0
  18. data/data/fonts/mplus1mn-regular-ascii-conums.ttf +0 -0
  19. data/data/fonts/mplus1p-bold-latin.ttf +0 -0
  20. data/data/fonts/mplus1p-light-latin.ttf +0 -0
  21. data/data/fonts/mplus1p-regular-latin.ttf +0 -0
  22. data/data/fonts/mplus1p-regular-multilingual.ttf +0 -0
  23. data/data/fonts/notoserif-bold-latin.ttf +0 -0
  24. data/data/fonts/notoserif-bolditalic-latin.ttf +0 -0
  25. data/data/fonts/notoserif-italic-latin.ttf +0 -0
  26. data/data/fonts/notoserif-regular-latin.ttf +0 -0
  27. data/data/themes/asciidoctor-theme.yml +174 -0
  28. data/data/themes/default-theme.yml +182 -0
  29. data/examples/chronicles.adoc +429 -0
  30. data/examples/chronicles.pdf +0 -0
  31. data/examples/example-pdf-screenshot.png +0 -0
  32. data/examples/example.adoc +27 -0
  33. data/examples/example.pdf +0 -0
  34. data/examples/sample-title-logo.jpg +0 -0
  35. data/examples/wolpertinger.jpg +0 -0
  36. data/lib/asciidoctor-pdf.rb +3 -0
  37. data/lib/asciidoctor-pdf/asciidoctor_ext.rb +1 -0
  38. data/lib/asciidoctor-pdf/asciidoctor_ext/section.rb +26 -0
  39. data/lib/asciidoctor-pdf/converter.rb +1365 -0
  40. data/lib/asciidoctor-pdf/core_ext/array.rb +5 -0
  41. data/lib/asciidoctor-pdf/core_ext/ostruct.rb +9 -0
  42. data/lib/asciidoctor-pdf/implicit_header_processor.rb +59 -0
  43. data/lib/asciidoctor-pdf/pdfmarks.rb +30 -0
  44. data/lib/asciidoctor-pdf/prawn_ext.rb +3 -0
  45. data/lib/asciidoctor-pdf/prawn_ext/coderay_encoder.rb +94 -0
  46. data/lib/asciidoctor-pdf/prawn_ext/extensions.rb +529 -0
  47. data/lib/asciidoctor-pdf/prawn_ext/formatted_text/formatter.rb +29 -0
  48. data/lib/asciidoctor-pdf/prawn_ext/formatted_text/parser.rb +1012 -0
  49. data/lib/asciidoctor-pdf/prawn_ext/formatted_text/parser.treetop +115 -0
  50. data/lib/asciidoctor-pdf/prawn_ext/formatted_text/transform.rb +178 -0
  51. data/lib/asciidoctor-pdf/roman_numeral.rb +107 -0
  52. data/lib/asciidoctor-pdf/theme_loader.rb +103 -0
  53. data/lib/asciidoctor-pdf/version.rb +5 -0
  54. metadata +248 -0
Binary file
Binary file
@@ -0,0 +1,27 @@
1
+ = Document Title
2
+ Doc Writer <doc@example.com>
3
+ :doctype: book
4
+ :source-highlighter: coderay
5
+ :listing-caption: Listing
6
+
7
+ A simple http://asciidoc.org[AsciiDoc] document.
8
+
9
+ == Introduction
10
+
11
+ A paragraph followed by a simple list with square bullets.
12
+
13
+ [square]
14
+ * item 1
15
+ * item 2
16
+
17
+ Here's how you say "`Hello, World!`" in Prawn:
18
+
19
+ .Create a basic PDF document using Prawn
20
+ [source,ruby]
21
+ ----
22
+ require 'prawn'
23
+
24
+ Prawn::Document.generate 'example.pdf' do
25
+ text 'Hello, World!'
26
+ end
27
+ ----
Binary file
Binary file
Binary file
@@ -0,0 +1,3 @@
1
+ require 'asciidoctor'
2
+ require_relative 'asciidoctor-pdf/version'
3
+ require_relative 'asciidoctor-pdf/converter'
@@ -0,0 +1 @@
1
+ require_relative 'asciidoctor_ext/section'
@@ -0,0 +1,26 @@
1
+ class Asciidoctor::Section
2
+ def numbered_title opts = {}
3
+ unless (@cached_numbered_title ||= nil)
4
+ if (slevel = (@level == 0 && @special ? 1 : @level)) == 0
5
+ @is_numbered = false
6
+ @cached_numbered_title = @cached_formal_numbered_title = title
7
+ elsif @numbered && !@caption && slevel <= (@document.attr 'sectnumlevels', 3).to_i
8
+ @is_numbered = true
9
+ @cached_numbered_title = %(#{sectnum} #{title})
10
+ @cached_formal_numbered_title = if slevel == 1 && @document.doctype == 'book'
11
+ %(Chapter #{@cached_numbered_title})
12
+ else
13
+ @cached_numbered_title
14
+ end
15
+ else
16
+ @is_numbered = false
17
+ @cached_numbered_title = @cached_formal_numbered_title = captioned_title
18
+ end
19
+ end
20
+ opts[:formal] ? @cached_formal_numbered_title : @cached_numbered_title
21
+ end unless respond_to? :numbered_title
22
+
23
+ def chapter?
24
+ @document.doctype == 'book' && @level == 1 || (@special && @level == 0)
25
+ end
26
+ end
@@ -0,0 +1,1365 @@
1
+ # TODO cleanup imports...decide what belongs in asciidoctor-pdf.rb
2
+ require_relative 'core_ext/array'
3
+ require 'prawn'
4
+ require 'prawn-svg'
5
+ require 'prawn/table'
6
+ require 'prawn/templates'
7
+ require_relative 'prawn_ext'
8
+ require_relative 'pdfmarks'
9
+ require_relative 'asciidoctor_ext'
10
+ require_relative 'implicit_header_processor'
11
+ require_relative 'theme_loader'
12
+ require_relative 'roman_numeral'
13
+
14
+ Asciidoctor::Extensions.register :pdf do
15
+ include_processor Asciidoctor::Pdf::ImplicitHeaderProcessor if @document.backend == 'pdf'
16
+ end
17
+
18
+ module Asciidoctor
19
+ module Pdf
20
+ class Converter < ::Prawn::Document
21
+ include ::Asciidoctor::Converter
22
+ include ::Asciidoctor::Writer
23
+ include ::Asciidoctor::Prawn::Extensions
24
+
25
+ register_for 'pdf'
26
+
27
+ def self.unicode_char number
28
+ [number].pack 'U*'
29
+ end
30
+
31
+ IndentationRx = /^ +/
32
+ TabSpaces = ' ' * 4
33
+ NoBreakSpace = unicode_char 0x00a0
34
+ NarrowNoBreakSpace = unicode_char 0x202f
35
+ HairSpace = unicode_char 0x200a
36
+ DotLeader = %(#{HairSpace}.)
37
+ EmDash = unicode_char 0x2014
38
+ LowercaseGreekA = unicode_char 0x03b1
39
+ AdmonitionIcons = {
40
+ note: (unicode_char 0xf0eb)
41
+ }
42
+ Bullets = {
43
+ disc: (unicode_char 0x2022),
44
+ circle: (unicode_char 0x25e6),
45
+ square: (unicode_char 0x25aa)
46
+ }
47
+ BuiltInEntityChars = {
48
+ '&lt;' => '<',
49
+ '&gt;' => '>',
50
+ '&amp;' => '&'
51
+ }
52
+ BuiltInEntityCharsRx = /(?:#{BuiltInEntityChars.keys * '|'})/
53
+ ImageAttributeValueRx = /^image:{1,2}(.*?)\[(.*?)\]$/
54
+
55
+ def initialize backend, opts
56
+ super
57
+ basebackend 'html'
58
+ outfilesuffix '.pdf'
59
+ #htmlsyntax 'xml'
60
+ @list_numbers = []
61
+ @list_bullets = []
62
+ end
63
+
64
+ def convert node, name = nil
65
+ method_name = %(convert_#{name ||= node.node_name})
66
+ result = nil
67
+ if respond_to? method_name
68
+ # NOTE we prepend the prefix "convert_" to avoid conflict with Prawn methods
69
+ result = send method_name, node
70
+ else
71
+ # TODO delegate to convert_method_missing
72
+ warn %(asciidoctor: WARNING: conversion missing in backend #{@backend} for #{name})
73
+ end
74
+ # NOTE inline nodes generate pseudo-HTML strings; the remainder write directly to PDF object
75
+ (node.is_a? ::Asciidoctor::Inline) ? result : self
76
+ end
77
+
78
+ def convert_content_for_block node, opts = {}
79
+ if self != (prev_converter = node.document.converter)
80
+ node.document.instance_variable_set :@converter, self
81
+ else
82
+ prev_converter = nil
83
+ end
84
+ if node.blocks?
85
+ node.content
86
+ elsif node.content_model != :compound && (string = node.content)
87
+ # TODO this content could be catched on repeat invocations!
88
+ layout_prose string, opts
89
+ end
90
+ node.document.instance_variable_set :@converter, prev_converter if prev_converter
91
+ end
92
+
93
+ def convert_document doc
94
+ init_pdf doc
95
+ # data-uri doesn't apply to PDF, so explicitly disable (is there a better place?)
96
+ doc.attributes.delete 'data-uri'
97
+
98
+ # TODO implement page_background_image as alternative and/or page_watermark_image
99
+ if (bg_color = @theme.page_background_color) && !(['transparent', 'FFFFFF'].include? bg_color.to_s)
100
+ on_page_create do
101
+ canvas do
102
+ fill_bounds bg_color.to_s
103
+ end
104
+ end
105
+ end
106
+
107
+ layout_cover_page :front, doc
108
+ layout_title_page doc
109
+
110
+ start_new_page
111
+ font @theme.base_font_family, size: @theme.base_font_size
112
+ convert_content_for_block doc
113
+
114
+ num_toc_levels = (doc.attr 'toclevels', 2).to_i
115
+ toc_page_nums = if doc.attr? 'toc'
116
+ layout_toc doc, num_toc_levels
117
+ else
118
+ (0..-1)
119
+ end
120
+
121
+ # TODO enable pagenums by default (perhaps upstream?)
122
+ stamp_page_numbers skip: (toc_page_nums.to_a.size + 1) if doc.attr 'pagenums'
123
+ add_outline doc, num_toc_levels, toc_page_nums
124
+ catalog.data[:ViewerPreferences] = [:FitWindow]
125
+
126
+ layout_cover_page :back, doc
127
+
128
+ # NOTE we have to init pdfmarks here while we have a reference to the doc
129
+ @pdfmarks = Pdfmarks.new doc
130
+ end
131
+
132
+ # NOTE embedded only makes sense if perhaps we are building
133
+ # on an existing Prawn::Document instance; for now, just treat
134
+ # it the same as a full document.
135
+ alias :convert_embedded :convert_document
136
+
137
+ # TODO only allow method to be called once (or we need a reset)
138
+ def init_pdf doc
139
+ theme = ThemeLoader.load_theme doc.attr('pdf-style'), doc.attr('pdf-stylesdir')
140
+ pdf_opts = (build_pdf_options doc, theme)
141
+ ::Prawn::Document.instance_method(:initialize).bind(self).call pdf_opts
142
+ # QUESTION should ThemeLoader register fonts?
143
+ register_fonts theme.font_catalog, (doc.attr 'scripts', 'latin')
144
+ @theme = theme
145
+ @font_color = theme.base_font_color
146
+ init_scratch_prototype
147
+ self
148
+ end
149
+
150
+ def build_pdf_options doc, theme
151
+ pdf_opts = {
152
+ #compress: true,
153
+ #optimize_objects: true,
154
+ info: (build_pdf_info doc),
155
+ margin: (theme.page_margin || 36),
156
+ page_layout: (theme.page_layout || :portrait).to_sym,
157
+ page_size: (theme.page_size || 'LETTER').upcase,
158
+ skip_page_creation: true,
159
+ }
160
+ # FIXME fix the namespace for FormattedTextFormatter
161
+ pdf_opts[:text_formatter] ||= ::Asciidoctor::Prawn::FormattedTextFormatter.new theme: theme
162
+ pdf_opts
163
+ end
164
+
165
+ def build_pdf_info doc
166
+ info = {}
167
+ # TODO create helper method for creating literal PDF string
168
+ info[:Title] = ::PDF::Core::LiteralString.new(doc.doctitle sanitize: true, use_fallback: true)
169
+ if doc.attr? 'authors'
170
+ info[:Author] = ::PDF::Core::LiteralString.new(doc.attr 'authors')
171
+ end
172
+ if doc.attr? 'subject'
173
+ info[:Subject] = ::PDF::Core::LiteralString.new(doc.attr 'subject')
174
+ end
175
+ if doc.attr? 'keywords'
176
+ info[:Keywords] = ::PDF::Core::LiteralString.new(doc.attr 'keywords')
177
+ end
178
+ if (doc.attr? 'publisher')
179
+ info[:Producer] = ::PDF::Core::LiteralString.new(doc.attr 'publisher')
180
+ end
181
+ info[:Creator] = ::PDF::Core::LiteralString.new %(Asciidoctor PDF #{::Asciidoctor::Pdf::VERSION}, based on Prawn #{::Prawn::VERSION})
182
+ info[:Producer] ||= (info[:Author] || info[:Creator])
183
+ # FIXME use docdate attribute
184
+ info[:ModDate] = info[:CreationDate] = ::Time.now
185
+ info
186
+ end
187
+
188
+ def convert_section sect, opts = {}
189
+ heading_level = sect.level + 1
190
+ theme_font :heading, level: heading_level do
191
+ title = sect.numbered_title formal: true
192
+ unless at_page_top?
193
+ if sect.chapter?
194
+ start_new_chapter sect
195
+ # FIXME smarter calculation here!!
196
+ elsif cursor < (height_of title) + @theme.heading_margin_top + @theme.heading_margin_bottom + @theme.base_line_height_length * 1.5
197
+ start_new_page
198
+ end
199
+ end
200
+ # QUESTION should we store page_start & destination in internal map?
201
+ sect.set_attr 'page_start', page_number
202
+ dest_y = at_page_top? ? page_height : y
203
+ sect.set_attr 'destination', (sect_destination = (dest_xyz 0, dest_y))
204
+ add_dest sect.id, sect_destination
205
+ sect.chapter? ? (layout_chapter_title sect, title) : (layout_heading title)
206
+ end
207
+
208
+ convert_content_for_block sect
209
+ sect.set_attr 'page_end', page_number
210
+ end
211
+
212
+ def convert_floating_title node
213
+ theme_font :heading, level: (node.level + 1) do
214
+ layout_heading node.title
215
+ end
216
+ end
217
+
218
+ def convert_abstract node
219
+ pad_box @theme.abstract_padding do
220
+ theme_font :abstract do
221
+ # FIXME control first_line_options using theme
222
+ prose_opts = { line_height: @theme.abstract_line_height, first_line_options: { styles: [font_style, :bold] } }
223
+ # FIXME make this cleaner!!
224
+ if node.blocks?
225
+ node.blocks.each do |child|
226
+ # FIXME is playback necessary here?
227
+ child.document.playback_attributes child.attributes
228
+ if child.context == :paragraph
229
+ layout_prose child.content, prose_opts
230
+ prose_opts.delete :first_line_options
231
+ else
232
+ # FIXME this could do strange things if the wrong kind of content shows up
233
+ convert_content_for_block child
234
+ end
235
+ end
236
+ elsif node.content_model != :compound && (string = node.content)
237
+ layout_prose string, prose_opts
238
+ end
239
+ end
240
+ end
241
+ # QUESTION should we be adding margin below the abstract??
242
+ #move_down @theme.block_margin_bottom
243
+ #theme_margin :block, :bottom
244
+ end
245
+
246
+ def convert_preamble node
247
+ # FIXME should only use lead for first paragraph
248
+ # add lead role to first paragraph then delegate to convert_content_for_block
249
+ theme_font :lead do
250
+ convert_content_for_block node
251
+ end
252
+ end
253
+
254
+ # TODO add prose around image logic (use role to add special logic for headshot)
255
+ def convert_paragraph node
256
+ is_lead = false
257
+ prose_opts = {}
258
+ node.roles.each do |role|
259
+ case role
260
+ when 'text-left'
261
+ prose_opts[:align] = :left
262
+ when 'text-right'
263
+ prose_opts[:align] = :right
264
+ when 'text-justify'
265
+ prose_opts[:align] = :justify
266
+ when 'lead'
267
+ is_lead = true
268
+ #when 'signature'
269
+ # prose_opts[:size] = @theme.base_font_size_small
270
+ end
271
+ end
272
+
273
+ if is_lead
274
+ theme_font :lead do
275
+ layout_prose node.content, prose_opts
276
+ end
277
+ else
278
+ layout_prose node.content, prose_opts
279
+ end
280
+ end
281
+
282
+ # FIXME alignment of content is off
283
+ def convert_admonition node
284
+ #move_down @theme.block_margin_top unless at_page_top?
285
+ theme_margin :block, :top
286
+ keep_together do |box_height = nil|
287
+ #theme_font :admonition do
288
+ label = node.caption.upcase
289
+ label_width = width_of label
290
+ # FIXME use padding from theme
291
+ indent @theme.horizontal_rhythm, @theme.horizontal_rhythm do
292
+ if box_height
293
+ float do
294
+ bounding_box [0, cursor], width: label_width + @theme.horizontal_rhythm, height: box_height do
295
+ # IMPORTANT the label must fit in the alotted space or it shows up on another page!
296
+ # QUESTION anyway to prevent text overflow in the case it doesn't fit?
297
+ stroke_vertical_rule @theme.admonition_border_color, at: bounds.width
298
+ # HACK make title in this location look right
299
+ label_margin_top = node.title? ? @theme.caption_margin_inside : 0
300
+ layout_prose label, valign: :center, style: :bold, line_height: 1, margin_top: label_margin_top, margin_bottom: 0
301
+ end
302
+ end
303
+ end
304
+ indent label_width + @theme.horizontal_rhythm * 2 do
305
+ layout_caption node.title if node.title?
306
+ convert_content_for_block node
307
+ # HACK compensate for margin bottom of admonition content
308
+ move_up(@theme.prose_margin_bottom || @theme.vertical_rhythm)
309
+ end
310
+ end
311
+ #end
312
+ end
313
+ #move_down @theme.block_margin_bottom
314
+ theme_margin :block, :bottom
315
+ end
316
+
317
+ def convert_example node
318
+ #move_down @theme.block_margin_top unless at_page_top?
319
+ theme_margin :block, :top
320
+ keep_together do |box_height = nil|
321
+ caption_height = node.title? ? (layout_caption node) : 0
322
+ if box_height
323
+ float do
324
+ bounding_box [0, cursor], width: bounds.width, height: box_height - caption_height do
325
+ theme_fill_and_stroke_bounds :example
326
+ end
327
+ end
328
+ end
329
+ pad_box [@theme.vertical_rhythm, @theme.horizontal_rhythm, 0, @theme.horizontal_rhythm] do
330
+ theme_font :example do
331
+ convert_content_for_block node
332
+ end
333
+ end
334
+ end
335
+ #move_down @theme.block_margin_bottom
336
+ theme_margin :block, :bottom
337
+ end
338
+
339
+ def convert_open node
340
+ case node.style
341
+ when 'abstract'
342
+ convert_abstract node
343
+ when 'partintro'
344
+ # FIXME cuts off any content beyond first paragraph!!
345
+ if node.blocks.size == 1 && node.blocks.first.style == 'abstract'
346
+ convert_abstract node.blocks.first
347
+ else
348
+ convert_content_for_block node
349
+ end
350
+ else
351
+ convert_content_for_block node
352
+ end
353
+ end
354
+
355
+ def convert_quote_or_verse node
356
+ border_width = @theme.blockquote_border_width
357
+ #move_down @theme.block_margin_top unless at_page_top?
358
+ theme_margin :block, :top
359
+ keep_together do |box_height = nil|
360
+ start_cursor = cursor
361
+ # FIXME use padding from theme
362
+ pad_box [@theme.vertical_rhythm / 2.0, @theme.horizontal_rhythm, -(@theme.vertical_rhythm / 2.0), @theme.horizontal_rhythm + border_width / 2.0] do
363
+ theme_font :blockquote do
364
+ if node.context == :quote
365
+ convert_content_for_block node
366
+ else # verse
367
+ layout_prose node.content, preserve: true, normalize: false, align: :left
368
+ end
369
+ end
370
+ theme_font :blockquote_cite do
371
+ if node.attr? 'attribution'
372
+ layout_prose %(#{EmDash} #{[(node.attr 'attribution'), (node.attr 'citetitle')].compact * ', '}), align: :left, normalize: false
373
+ end
374
+ end
375
+ end
376
+ if box_height
377
+ # QUESTION should we use bounding_box + stroke_vertical_rule instead?
378
+ save_graphics_state do
379
+ stroke_color @theme.blockquote_border_color
380
+ line_width border_width
381
+ stroke_vertical_line cursor, start_cursor, at: border_width / 2.0
382
+ end
383
+ end
384
+ end
385
+ #move_down @theme.block_margin_bottom
386
+ theme_margin :block, :bottom
387
+ end
388
+
389
+ alias :convert_quote :convert_quote_or_verse
390
+ alias :convert_verse :convert_quote_or_verse
391
+
392
+ def convert_sidebar node
393
+ #move_down @theme.block_margin_top unless at_page_top?
394
+ theme_margin :block, :top
395
+ keep_together do |box_height = nil|
396
+ if box_height
397
+ float do
398
+ bounding_box [0, cursor], width: bounds.width, height: box_height do
399
+ theme_fill_and_stroke_bounds :sidebar
400
+ end
401
+ end
402
+ end
403
+ pad_box @theme.block_padding do
404
+ if node.title?
405
+ theme_font :sidebar_title do
406
+ # QUESTION should we allow margins of sidebar title to be customized?
407
+ layout_heading node.title, align: @theme.sidebar_title_align.to_sym, margin_top: 0
408
+ end
409
+ end
410
+ theme_font :sidebar do
411
+ convert_content_for_block node
412
+ end
413
+ # HACK compensate for margin bottom of sidebar content
414
+ move_up(@theme.prose_margin_bottom || @theme.vertical_rhythm)
415
+ end
416
+ end
417
+ #move_down @theme.block_margin_bottom
418
+ theme_margin :block, :bottom
419
+ end
420
+
421
+ def convert_colist node
422
+ # HACK undo the margin below the listing
423
+ move_up ((@theme.block_margin_bottom || @theme.vertical_rhythm) * 0.5)
424
+ @list_numbers ||= []
425
+ # FIXME move \u2460 to constant (or theme setting)
426
+ @list_numbers << %(\u2460)
427
+ #stroke_horizontal_rule @theme.caption_border_bottom_color
428
+ # HACK fudge spacing around colist a bit; each item is shifted up by this amount (see convert_list_item)
429
+ move_down ((@theme.prose_margin_bottom || @theme.vertical_rhythm) * 0.5)
430
+ convert_outline_list node
431
+ @list_numbers.pop
432
+ end
433
+
434
+ def convert_dlist node
435
+ node.items.each do |terms, desc|
436
+ terms = [*terms]
437
+ # NOTE don't orphan the terms, allow for at least one line of content
438
+ # FIXME extract ensure_space (or similar) method
439
+ start_new_page if cursor < @theme.base_line_height_length * (terms.size + 1)
440
+ terms.each do |term|
441
+ layout_prose term.text, style: @theme.description_list_term_font_style.to_sym, margin_top: 0, margin_bottom: (@theme.vertical_rhythm / 3.0), align: :left
442
+ end
443
+ if desc
444
+ indent @theme.description_list_description_indent do
445
+ convert_content_for_list_item desc
446
+ end
447
+ end
448
+ end
449
+ end
450
+
451
+ def convert_olist node
452
+ @list_numbers ||= []
453
+ list_number = case node.style
454
+ when 'arabic'
455
+ '1'
456
+ when 'decimal'
457
+ '01'
458
+ when 'loweralpha'
459
+ 'a'
460
+ when 'upperalpha'
461
+ 'A'
462
+ when 'lowerroman'
463
+ RomanNumeral.new 'i'
464
+ when 'upperroman'
465
+ RomanNumeral.new 'I'
466
+ when 'lowergreek'
467
+ LowercaseGreekA
468
+ else
469
+ '1'
470
+ end
471
+ if (skip = (node.attr 'start', 1).to_i - 1) > 0
472
+ skip.times { list_number = list_number.next }
473
+ end
474
+ @list_numbers << list_number
475
+ convert_outline_list node
476
+ @list_numbers.pop
477
+ end
478
+
479
+ # TODO implement checklist
480
+ def convert_ulist node
481
+ bullet_type = if (style = node.style)
482
+ case style
483
+ when 'bibliography'
484
+ :square
485
+ else
486
+ style.to_sym
487
+ end
488
+ else
489
+ case (node.level % 3)
490
+ when 1
491
+ :disc
492
+ when 2
493
+ :circle
494
+ when 0
495
+ :square
496
+ end
497
+ end
498
+ @list_bullets << Bullets[bullet_type]
499
+ convert_outline_list node
500
+ @list_bullets.pop
501
+ end
502
+
503
+ def convert_outline_list node
504
+ indent @theme.outline_list_indent do
505
+ node.items.each do |item|
506
+ convert_list_item item
507
+ end
508
+ end
509
+ # NOTE children will provide the necessary bottom margin
510
+ end
511
+
512
+ def convert_list_item node
513
+ # HACK quick hack to tighten items on colist
514
+ if node.parent.context == :colist
515
+ move_up ((@theme.prose_margin_bottom || @theme.vertical_rhythm) * 0.5)
516
+ end
517
+
518
+ # NOTE we need at least one line of content, so move down if we don't have it
519
+ # FIXME extract ensure_space (or similar) method
520
+ start_new_page if cursor < @theme.base_line_height_length
521
+
522
+ # TODO move this to a draw_bullet method
523
+ float do
524
+ bounding_box [-@theme.outline_list_indent, cursor], width: @theme.outline_list_indent do
525
+ label = case node.parent.context
526
+ when :ulist
527
+ @list_bullets.last
528
+ when :olist
529
+ @list_numbers << (index = @list_numbers.pop).next
530
+ %(#{index}.)
531
+ when :colist
532
+ @list_numbers << (index = @list_numbers.pop).next
533
+ # FIXME cleaner way to do numbers in colist; need more room around number
534
+ theme_font :conum do
535
+ # QUESTION should this be align: :left or :center?
536
+ layout_prose index, align: :left, line_height: @theme.conum_line_height, inline_format: false, margin: 0
537
+ end
538
+ next # short circuit label
539
+ end
540
+ layout_prose label, align: :center, normalize: false, inline_format: false, margin: 0
541
+ end
542
+ end
543
+ convert_content_for_list_item node
544
+ end
545
+
546
+ def convert_content_for_list_item node
547
+ if node.text?
548
+ opts = {}
549
+ opts[:align] = :left if node.parent.style == 'bibliography'
550
+ layout_prose node.text, opts
551
+ end
552
+ convert_content_for_block node
553
+ end
554
+
555
+ def convert_image node
556
+ #move_down @theme.block_margin_top unless at_page_top?
557
+ theme_margin :block, :top
558
+ target = node.attr 'target'
559
+ #if target.end_with? '.pdf'
560
+ # import_page target
561
+ # return
562
+ #end
563
+
564
+ # FIXME use normalize_path here!
565
+ image_path = File.join((node.attr 'docdir'), (node.attr 'imagesdir') || '', target)
566
+ # TODO extension should be an attribute on an image node
567
+ image_type = File.extname(image_path)[1..-1]
568
+ width = if node.attr? 'scaledwidth'
569
+ ((node.attr 'scaledwidth').to_f / 100.0) * bounds.width
570
+ elsif image_type == 'svg'
571
+ bounds.width
572
+ elsif node.attr? 'width'
573
+ (node.attr 'width').to_f
574
+ else
575
+ bounds.width * (@theme.image_scaled_width_default || 0.75)
576
+ end
577
+ height = nil
578
+ position = ((node.attr 'align') || @theme.image_align_default || :left).to_sym
579
+ case image_type
580
+ when 'svg'
581
+ keep_together do
582
+ # HACK prawn-svg can't seem to center, so do it manually for now
583
+ left = case position
584
+ when :left
585
+ 0
586
+ when :right
587
+ bounds.width - width
588
+ when :center
589
+ ((bounds.width - width) / 2.0).floor
590
+ end
591
+ svg IO.read(image_path), at: [left, cursor], width: width, position: position
592
+ layout_caption node, position: :bottom if node.title?
593
+ end
594
+ else
595
+ begin
596
+ # FIXME temporary workaround to group caption & image
597
+ # Prawn doesn't provide access to rendered width and height before placing the
598
+ # image on the page
599
+ image_obj, image_info = build_image_object node.image_uri image_path
600
+ rendered_w, rendered_h = image_info.calc_image_dimensions width: width
601
+ caption_height = node.title? ?
602
+ (@theme.caption_margin_inside + @theme.caption_margin_outside + @theme.base_line_height_length) : 0
603
+ if cursor < rendered_h + caption_height
604
+ start_new_page
605
+ if cursor < rendered_h + caption_height
606
+ height = (cursor - caption_height).floor
607
+ width = ((rendered_w * height) / rendered_h).floor
608
+ # FIXME workaround to fix Prawn not adding fill and stroke commands
609
+ # on page that only has an image; breakage occurs when line numbers are added
610
+ fill_color self.fill_color
611
+ stroke_color self.stroke_color
612
+ end
613
+ end
614
+ embed_image image_obj, image_info, width: width, height: height, position: position
615
+ rescue => e
616
+ warn %(asciidoctor: WARNING: could not embed image; #{e.message})
617
+ return
618
+ end
619
+ layout_caption node, position: :bottom if node.title?
620
+ end
621
+ #move_down @theme.block_margin_bottom
622
+ theme_margin :block, :bottom
623
+ end
624
+
625
+ def convert_listing_or_literal node
626
+ # HACK disable built-in syntax highlighter; must be done before calling node.content!
627
+ if (node.style == 'source')
628
+ node.subs.delete :highlight
629
+ end
630
+ # FIXME highlighter freaks out about the non-breaking space characters
631
+ source_string = prepare_verbatim node.content
632
+ source_chunks = if node.context == :listing && (node.attr? 'language') && (node.attr? 'source-highlighter')
633
+ case node.attr 'source-highlighter'
634
+ when 'coderay'
635
+ # FIXME use autoload here!
636
+ require_relative 'prawn_ext/coderay_encoder' unless defined? ::Asciidoctor::Prawn::CodeRayEncoder
637
+ (::CodeRay.scan source_string, (node.attr 'language', 'text').to_sym).to_prawn
638
+ when 'pygments'
639
+ # FIXME use autoload here!
640
+ require 'pygments.rb' unless defined? ::Pygments
641
+ # FIXME if lexer is nil, we don't escape specialchars!
642
+ if (lexer = ::Pygments::Lexer[(node.attr 'language')])
643
+ pygments_config = { nowrap: true, noclasses: true, style: ((node.document.attr 'pygments-style') || 'pastie') }
644
+ result = lexer.highlight(source_string, options: pygments_config)
645
+ result = result.gsub(/(?: <span style="font-style: italic">(?:\/\/|#) &lt;(?<num>\d+)&gt;<\/span>| &lt;(?<num>\d+)&gt;)$/) {
646
+ # FIXME move \u2460 to constant (or theme setting)
647
+ num = %(\u2460)
648
+ (($~[:num]).to_i - 1).times { num = num.next }
649
+ if (conum_color = @theme.conum_font_color)
650
+ %( <color rgb="#{conum_color}">#{num}</color>)
651
+ end
652
+ }
653
+ text_formatter.format result
654
+ end
655
+ end
656
+ end
657
+ source_chunks ||= [{ text: source_string }]
658
+
659
+ #move_down @theme.block_margin_top unless at_page_top?
660
+ theme_margin :block, :top
661
+
662
+ keep_together do |box_height = nil|
663
+ caption_height = node.title? ? (layout_caption node) : 0
664
+ theme_font :code do
665
+ if box_height
666
+ float do
667
+ bounding_box [0, cursor], width: bounds.width, height: box_height - caption_height do
668
+ theme_fill_and_stroke_bounds :code
669
+ end
670
+ end
671
+ end
672
+
673
+ pad_box @theme.code_padding do
674
+ typeset_formatted_text source_chunks, (calc_line_metrics @theme.code_line_height), color: @theme.code_font_color
675
+ end
676
+ end
677
+ end
678
+ stroke_horizontal_rule @theme.caption_border_bottom_color if node.title? && @theme.caption_border_bottom_color
679
+
680
+ #move_down @theme.block_margin_bottom
681
+ theme_margin :block, :bottom
682
+ end
683
+
684
+ alias :convert_listing :convert_listing_or_literal
685
+ alias :convert_literal :convert_listing_or_literal
686
+
687
+ def convert_table node
688
+ num_rows = 0
689
+ num_cols = node.columns.size
690
+ table_header = false
691
+
692
+ table_data = []
693
+ node.rows[:head].each do |rows|
694
+ table_header = true
695
+ num_rows += 1
696
+ row_data = []
697
+ rows.each do |cell|
698
+ row_data << {
699
+ content: cell.text,
700
+ text_color: (@theme.table_head_font_color || @font_color),
701
+ inline_format: true,
702
+ font_style: :bold,
703
+ colspan: cell.colspan || 1,
704
+ rowspan: cell.rowspan || 1,
705
+ align: (cell.attr 'halign').to_sym,
706
+ valign: (cell.attr 'valign').to_sym
707
+ }
708
+ end
709
+ table_data << row_data
710
+ end
711
+
712
+ node.rows[:body].each do |rows|
713
+ num_rows += 1
714
+ row_data = []
715
+ rows.each do |cell|
716
+ cell_data = {
717
+ content: cell.text,
718
+ text_color: (@theme.table_body_font_color || @font_color),
719
+ inline_format: true,
720
+ colspan: cell.colspan || 1,
721
+ rowspan: cell.rowspan || 1,
722
+ align: (cell.attr 'halign').to_sym,
723
+ valign: (cell.attr 'valign').to_sym
724
+ }
725
+ case cell.style
726
+ when :emphasis
727
+ cell_data[:font_style] = :italic
728
+ when :strong, :header
729
+ cell_data[:font_style] = :bold
730
+ when :monospaced
731
+ cell_data[:font] = @theme.literal_font_family
732
+ if (size = @theme.literal_font_size)
733
+ cell_data[:size] = size
734
+ end
735
+ if (color = @theme.literal_font_color)
736
+ cell_data[:text_color] = color
737
+ end
738
+ # TODO finish me
739
+ end
740
+ row_data << cell_data
741
+ end
742
+ table_data << row_data
743
+ end
744
+
745
+ # TODO support footer row
746
+
747
+ column_widths = node.columns.map {|col| ((col.attr 'colpcwidth') * bounds.width) / 100.0 }
748
+
749
+ border = {}
750
+ table_border_width = @theme.table_border_width
751
+ [:top, :bottom, :left, :right, :cols, :rows].each {|edge| border[edge] = table_border_width }
752
+
753
+ frame = (node.attr 'frame') || 'all'
754
+ grid = (node.attr 'grid') || 'all'
755
+
756
+ case grid
757
+ when 'cols'
758
+ border[:rows] = 0
759
+ when 'rows'
760
+ border[:cols] = 0
761
+ when 'none'
762
+ border[:rows] = border[:cols] = 0
763
+ end
764
+
765
+ case frame
766
+ when 'topbot'
767
+ border[:left] = border[:right] = 0
768
+ when 'sides'
769
+ border[:top] = border[:bottom] = 0
770
+ when 'none'
771
+ border[:top] = border[:right] = border[:bottom] = border[:left] = 0
772
+ end
773
+
774
+ table_settings = {
775
+ header: table_header,
776
+ cell_style: {
777
+ padding: @theme.table_cell_padding,
778
+ border_width: 0,
779
+ border_color: @theme.table_border_color
780
+ },
781
+ column_widths: column_widths,
782
+ row_colors: ['FFFFFF', @theme.table_background_color_alt]
783
+ }
784
+
785
+ theme_margin :block, :top
786
+ layout_caption node if node.title?
787
+
788
+ table table_data, table_settings do
789
+ if grid == 'none' && frame == 'none'
790
+ if table_header
791
+ rows(0).border_bottom_width = 1.5
792
+ end
793
+ else
794
+ # apply the grid setting first across all cells
795
+ cells.border_width = [border[:rows], border[:cols], border[:rows], border[:cols]]
796
+
797
+ if table_header
798
+ rows(0).border_bottom_width = 1.5
799
+ end
800
+
801
+ # top edge of table
802
+ rows(0).border_top_width = border[:top]
803
+ # right edge of table
804
+ columns(num_cols - 1).border_right_width = border[:right]
805
+ # bottom edge of table
806
+ rows(num_rows - 1).border_bottom_width = border[:bottom]
807
+ # left edge of table
808
+ columns(0).border_left_width = border[:left]
809
+ end
810
+ end
811
+ theme_margin :block, :bottom
812
+ end
813
+
814
+ def convert_thematic_break node
815
+ #move_down @theme.thematic_break_margin_top
816
+ theme_margin :thematic_break, :top
817
+ stroke_horizontal_rule @theme.thematic_break_border_color, line_width: @theme.thematic_break_border_width
818
+ #move_down @theme.thematic_break_margin_bottom
819
+ theme_margin :thematic_break, :bottom
820
+ end
821
+
822
+ # deprecated
823
+ alias :convert_horizontal_rule :convert_thematic_break
824
+
825
+ # NOTE can't alias to start_new_page since methods have different arity
826
+ def convert_page_break node
827
+ start_new_page unless at_page_top?
828
+ end
829
+
830
+ def convert_inline_anchor node
831
+ target = node.target
832
+ case node.type
833
+ when :xref
834
+ refid = (node.attr 'refid') || target
835
+ # NOTE we lookup text in converter because DocBook doesn't need this logic
836
+ if (text = node.text || (node.document.references[:ids][refid] || %([#{refid}])))
837
+ # FIXME shouldn't target be refid? logic seems confused here
838
+ %(<link anchor="#{target}">#{text}</link>)
839
+ # FIXME hack for bibliography references
840
+ # should be able to reenable once we parse inline destinations
841
+ else
842
+ %((see [#{refid}]))
843
+ end
844
+ when :ref
845
+ #%(<a id="#{target}"></a>)
846
+ ''
847
+ when :bibref
848
+ #%(<a id="#{target}"></a>[#{target}])
849
+ %([#{target}])
850
+ when :link
851
+ attrs = []
852
+ #attrs << %( id="#{node.id}") if node.id
853
+ if (role = node.role)
854
+ attrs << %( class="#{role}")
855
+ end
856
+ #attrs << %( title="#{node.attr 'title'}") if node.attr? 'title'
857
+ attrs << %( target="#{node.attr 'window'}") if node.attr? 'window'
858
+ if (node.document.attr? 'showlinks') && !(node.has_role? 'bare')
859
+ # TODO cleanup look, perhaps put target in smaller text
860
+ %(<link href="#{target}"#{attrs.join}>#{node.text}</a> (#{target}))
861
+ else
862
+ %(<link href="#{target}"#{attrs.join}>#{node.text}</a>)
863
+ end
864
+ else
865
+ warn %(asciidoctor: WARNING: unknown anchor type: #{node.type.inspect})
866
+ end
867
+ end
868
+
869
+ def convert_inline_break node
870
+ %(#{node.text}<br>)
871
+ end
872
+
873
+ def convert_inline_button node
874
+ %(<b>[#{NarrowNoBreakSpace}#{node.text}#{NarrowNoBreakSpace}]</b>)
875
+ end
876
+
877
+ def convert_inline_footnote node
878
+ if (index = node.attr 'index')
879
+ #text = node.document.footnotes.find {|fn| fn.index == index }.text
880
+ %( [#{node.text}])
881
+ elsif node.type == :xref
882
+ %( <color rgb="FF0000">[#{node.text}]</color>)
883
+ end
884
+ end
885
+
886
+ def convert_inline_kbd node
887
+ if (keys = node.attr 'keys').size == 1
888
+ %(<code>#{keys[0]}</code>)
889
+ else
890
+ keys.map {|key| %(<code>#{key}</code>+) }.join.chop
891
+ end
892
+ end
893
+
894
+ def convert_inline_menu node
895
+ menu = node.attr 'menu'
896
+ if !(submenus = node.attr 'submenus').empty?
897
+ %(<strong>#{[menu, *submenus, (node.attr 'menuitem')] * ' | '}</strong>)
898
+ elsif (menuitem = node.attr 'menuitem')
899
+ %(<strong>#{menu} | #{menuitem}</strong>)
900
+ else
901
+ %(<strong>#{menu}</strong>)
902
+ end
903
+ end
904
+
905
+ def convert_inline_quoted node
906
+ case node.type
907
+ when :emphasis
908
+ open, close, is_tag = ['<em>', '</em>', true]
909
+ when :strong
910
+ open, close, is_tag = ['<strong>', '</strong>', true]
911
+ when :monospaced
912
+ open, close, is_tag = ['<code>', '</code>', true]
913
+ when :superscript
914
+ open, close, is_tag = ['<sup>', '</sup>', true]
915
+ when :subscript
916
+ open, close, is_tag = ['<sub>', '</sub>', true]
917
+ when :double
918
+ open, close, is_tag = ['“', '”', false]
919
+ when :single
920
+ open, close, is_tag = ['‘', '’', false]
921
+ #when :asciimath, :latexmath
922
+ else
923
+ open, close, is_tag = [nil, nil, false]
924
+ end
925
+
926
+ if (role = node.role)
927
+ if is_tag
928
+ quoted_text = %(#{open.chop} class="#{role}">#{node.text}#{close})
929
+ else
930
+ quoted_text = %(<span class="#{role}">#{open}#{node.text}#{close}</span>)
931
+ end
932
+ else
933
+ quoted_text = %(#{open}#{node.text}#{close})
934
+ end
935
+
936
+ node.id ? %(<a id="#{node.id}"></a>#{quoted_text}) : quoted_text
937
+ end
938
+
939
+ def layout_title_page doc
940
+ return unless doc.header? && !doc.noheader && !doc.notitle
941
+
942
+ start_new_page
943
+ # IMPORTANT this is the first page created, so we need to set the base font
944
+ font @theme.base_font_family, size: @theme.base_font_size
945
+
946
+ # TODO treat title-logo like front and back cover images
947
+ if doc.attr? 'title-logo'
948
+ # FIXME theme setting
949
+ move_down @theme.vertical_rhythm * 2
950
+ # FIXME add API to Asciidoctor for creating blocks like this (extract from extensions module?)
951
+ image = ::Asciidoctor::Block.new doc, :image, content_model: :empty
952
+ attrs = { 'target' => (doc.attr 'title-logo'), 'align' => 'center' }
953
+ image.update_attributes attrs
954
+ convert_image image
955
+ # FIXME theme setting
956
+ move_down @theme.vertical_rhythm * 4
957
+ end
958
+
959
+ # FIXME only create title page if doctype=book!
960
+ # FIXME honor subtitle!
961
+ theme_font :heading, level: 1 do
962
+ layout_heading doc.doctitle, align: :center
963
+ end
964
+ # FIXME theme setting
965
+ move_down @theme.vertical_rhythm
966
+ if doc.attr? 'authors'
967
+ layout_prose doc.attr('authors'), align: :center, margin_top: 0, margin_bottom: @theme.vertical_rhythm / 2.0, normalize: false
968
+ end
969
+ layout_prose [(doc.attr? 'revnumber') ? %(#{doc.attr 'version-label'} #{doc.attr 'revnumber'}) : nil, (doc.attr 'revdate')].compact * "\n", align: :center, margin_top: @theme.vertical_rhythm * 5, margin_bottom: 0, normalize: false
970
+ end
971
+
972
+ def layout_cover_page position, doc
973
+ # TODO turn processing of attribute with inline image a utility function in Asciidoctor
974
+ if (cover_image = (doc.attr %(#{position}-cover-image)))
975
+ if cover_image =~ ImageAttributeValueRx
976
+ cover_image = %(#{resolve_imagesdir doc}#{$1})
977
+ end
978
+ # QUESTION should we go to page 1 when position == :front?
979
+ go_to_page page_count if position == :back
980
+ image_page cover_image, canvas: true
981
+ end
982
+ end
983
+
984
+ # NOTE can't alias to start_new_page since methods have different arity
985
+ # NOTE only called if not at page top
986
+ def start_new_chapter section
987
+ start_new_page
988
+ end
989
+
990
+ def layout_chapter_title node, title
991
+ layout_heading title
992
+ end
993
+
994
+ # QUESTION why doesn't layout_heading set the font??
995
+ def layout_heading string, opts = {}
996
+ margin_top = (margin = (opts.delete :margin)) || (opts.delete :margin_top) || @theme.heading_margin_top
997
+ margin_bottom = margin || (opts.delete :margin_bottom) || @theme.heading_margin_bottom
998
+ #move_down margin_top
999
+ self.margin_top margin_top
1000
+ typeset_text string, calc_line_metrics((opts.delete :line_height) || @theme.heading_line_height), {
1001
+ color: @font_color,
1002
+ inline_format: true,
1003
+ align: :left
1004
+ }.merge(opts)
1005
+ #move_down margin_bottom
1006
+ self.margin_bottom margin_bottom
1007
+ end
1008
+
1009
+ # NOTE inline_format is true by default
1010
+ def layout_prose string, opts = {}
1011
+ margin_top = (margin = (opts.delete :margin)) || (opts.delete :margin_top) || @theme.prose_margin_top || 0
1012
+ margin_bottom = margin || (opts.delete :margin_bottom) || @theme.prose_margin_bottom || @theme.vertical_rhythm
1013
+ if (anchor = opts.delete :anchor)
1014
+ # FIXME won't work if inline_format is true; should instead pass through as attribute w/ link color set
1015
+ if (link_color = opts.delete :link_color)
1016
+ string = %(<link anchor="#{anchor}"><color rgb="#{link_color}">#{string}</color></link>)
1017
+ else
1018
+ string = %(<link anchor="#{anchor}">#{string}</link>)
1019
+ end
1020
+ end
1021
+ if opts.delete :preserve
1022
+ # preserve leading space using non-breaking space chars
1023
+ string = string.gsub(IndentationRx) { NoBreakSpace * $&.length }
1024
+ end
1025
+ #move_down margin_top
1026
+ self.margin_top margin_top
1027
+ typeset_text string, calc_line_metrics((opts.delete :line_height) || @theme.base_line_height), {
1028
+ color: @font_color,
1029
+ # NOTE normalize makes endlines soft (replaces "\n" with ' ')
1030
+ inline_format: [{ normalize: (opts.delete :normalize) != false }],
1031
+ align: (@theme.base_align || :justify).to_sym
1032
+ }.merge(opts)
1033
+ #move_down margin_bottom
1034
+ self.margin_bottom margin_bottom
1035
+ end
1036
+
1037
+ # Render the caption and return the height of the rendered content
1038
+ # QUESTION should layout_caption check for title? and return 0 if false?
1039
+ # TODO allow margin to be zeroed
1040
+ def layout_caption subject, opts = {}
1041
+ mark = { cursor: cursor, page_number: page_number }
1042
+ case subject
1043
+ when ::String
1044
+ string = subject
1045
+ when ::Asciidoctor::AbstractBlock
1046
+ string = subject.title? ? subject.captioned_title : nil
1047
+ else
1048
+ return 0
1049
+ end
1050
+ theme_font :caption do
1051
+ if (position = (opts.delete :position) || :top) == :top
1052
+ margin = { top: @theme.caption_margin_outside, bottom: @theme.caption_margin_inside }
1053
+ else
1054
+ margin = { top: @theme.caption_margin_inside, bottom: @theme.caption_margin_outside }
1055
+ end
1056
+ layout_prose string, {
1057
+ margin_top: margin[:top],
1058
+ margin_bottom: margin[:bottom],
1059
+ align: (@theme.caption_align || :left).to_sym,
1060
+ normalize: false
1061
+ }.merge(opts)
1062
+ if position == :top && @theme.caption_border_bottom_color
1063
+ stroke_horizontal_rule @theme.caption_border_bottom_color
1064
+ # HACK move down slightly so line isn't covered by filled area (half width of line)
1065
+ move_down 0.25
1066
+ end
1067
+ end
1068
+ # NOTE we assume we don't clear more than one page
1069
+ if page_number > mark[:page_number]
1070
+ mark[:cursor] + (bounds.top - cursor)
1071
+ else
1072
+ mark[:cursor] - cursor
1073
+ end
1074
+ end
1075
+
1076
+ def layout_toc doc, num_levels = 2, toc_page_number = 2
1077
+ go_to_page toc_page_number - 1
1078
+ start_new_page
1079
+ theme_font :heading, level: 2 do
1080
+ layout_heading doc.attr('toc-title')
1081
+ end
1082
+ line_metrics = calc_line_metrics @theme.base_line_height
1083
+ dot_width = width_of DotLeader
1084
+ if num_levels > 0
1085
+ layout_toc_level doc.sections, num_levels, line_metrics, dot_width
1086
+ end
1087
+ toc_page_numbers = (toc_page_number..page_number)
1088
+ go_to_page page_count - 1
1089
+ toc_page_numbers
1090
+ end
1091
+
1092
+ def layout_toc_level sections, num_levels, line_metrics, dot_width
1093
+ sections.each do |sect|
1094
+ sect_title = sect.numbered_title
1095
+ sect_page_num = (sect.attr 'page_start') - 1
1096
+ # NOTE we do some cursor hacking so the dots don't affect vertical alignment
1097
+ start_cursor = cursor
1098
+ typeset_text %(<link anchor="#{sect.id}">#{sect_title}</link>), line_metrics, inline_format: true
1099
+ end_cursor = cursor
1100
+ move_cursor_to start_cursor
1101
+ num_dots = ((bounds.width - (width_of %(#{sect_title} #{sect_page_num}), inline_format: true)) / dot_width).floor
1102
+ typeset_formatted_text [text: %(#{DotLeader * num_dots} #{sect_page_num}), anchor: sect.id], line_metrics, align: :right
1103
+ move_cursor_to end_cursor
1104
+ if sect.level < num_levels
1105
+ indent @theme.horizontal_rhythm do
1106
+ layout_toc_level sect.sections, num_levels, line_metrics, dot_width
1107
+ end
1108
+ end
1109
+ end
1110
+ end
1111
+
1112
+ def stamp_page_numbers opts = {}
1113
+ skip = opts[:skip] || 1
1114
+ start = skip + 1
1115
+ pattern = page_number_pattern
1116
+ repeat (start..page_count), dynamic: true do
1117
+ # don't stamp pages which are imported / inserts
1118
+ next if page.imported_page?
1119
+ case (align = (page_number - skip).odd? ? :left : :right)
1120
+ when :left
1121
+ page_number_label = pattern[:left] % [page_number - skip]
1122
+ when :right
1123
+ page_number_label = pattern[:right] % [page_number - skip]
1124
+ end
1125
+ theme_font :footer do
1126
+ canvas do
1127
+ if @theme.footer_border_color && @theme.footer_border_color != 'transparent'
1128
+ save_graphics_state do
1129
+ line_width @theme.base_border_width
1130
+ stroke_color @theme.footer_border_color
1131
+ stroke_horizontal_line left_margin, bounds.width - right_margin, at: (page.margins[:bottom] / 2.0 + @theme.vertical_rhythm / 2.0)
1132
+ end
1133
+ end
1134
+ indent left_margin, right_margin do
1135
+ formatted_text_box [text: page_number_label, color: @theme.footer_font_color], at: [0, (page.margins[:bottom] / 2.0)], align: align
1136
+ end
1137
+ end
1138
+ end
1139
+ end
1140
+ end
1141
+
1142
+ def page_number_pattern
1143
+ { left: '%s', right: '%s' }
1144
+ end
1145
+
1146
+ # FIXME we are assuming we always have exactly one title page
1147
+ def add_outline doc, num_levels = 2, toc_page_nums = (0..-1)
1148
+ front_matter_counter = RomanNumeral.new 0, :lower
1149
+
1150
+ page_num_labels = {}
1151
+
1152
+ # title page (i)
1153
+ page_num_labels[0] = { P: ::PDF::Core::LiteralString.new(front_matter_counter.next!.to_s) }
1154
+
1155
+ # toc pages (ii..?)
1156
+ toc_page_nums.each do
1157
+ page_num_labels[front_matter_counter.to_i] = { P: ::PDF::Core::LiteralString.new(front_matter_counter.next!.to_s) }
1158
+ end
1159
+
1160
+ # credits page
1161
+ #page_num_labels[front_matter_counter.to_i] = { P: ::PDF::Core::LiteralString.new(front_matter_counter.next!.to_s) }
1162
+
1163
+ # number of front matter pages aside from the document title to skip in page number index
1164
+ numbering_offset = front_matter_counter.to_i - 1
1165
+
1166
+ outline.define do
1167
+ if (doctitle = (doc.doctitle sanitize: true, use_fallback: true))
1168
+ page title: doctitle, destination: (document.dest_top 1)
1169
+ end
1170
+ if doc.attr? 'toc'
1171
+ page title: doc.attr('toc-title'), destination: (document.dest_top toc_page_nums.first)
1172
+ end
1173
+ #page title: 'Credits', destination: (document.dest_top toc_page_nums.first + 1)
1174
+ # QUESTION any way to get add_outline_level to invoke in the context of the outline?
1175
+ document.add_outline_level self, doc.sections, num_levels, page_num_labels, numbering_offset
1176
+ end
1177
+
1178
+ catalog.data[:PageLabels] = state.store.ref Nums: page_num_labels.flatten
1179
+ catalog.data[:PageMode] = :UseOutlines
1180
+ nil
1181
+ end
1182
+
1183
+ # TODO only nest inside root node if doctype=article
1184
+ def add_outline_level outline, sections, num_levels, page_num_labels, numbering_offset
1185
+ sections.each do |sect|
1186
+ sect_title = sanitize(sect.numbered_title formal: true)
1187
+ sect_destination = sect.attr 'destination'
1188
+ sect_page_num = (sect.attr 'page_start') - 1
1189
+ page_num_labels[sect_page_num + numbering_offset] = { P: ::PDF::Core::LiteralString.new(sect_page_num.to_s) }
1190
+ if (subsections = sect.sections).empty? || sect.level == num_levels
1191
+ outline.page title: sect_title, destination: sect_destination
1192
+ elsif sect.level < num_levels + 1
1193
+ outline.section sect_title, { destination: sect_destination } do
1194
+ add_outline_level outline, subsections, num_levels, page_num_labels, numbering_offset
1195
+ end
1196
+ end
1197
+ end
1198
+ end
1199
+
1200
+ def write pdf_doc, target
1201
+ pdf_doc.render_file target
1202
+ #@prototype.render_file 'scratch.pdf'
1203
+ # QUESTION restore attributes first?
1204
+ @pdfmarks.generate_file target if @pdfmarks
1205
+ end
1206
+
1207
+ def register_fonts font_catalog, scripts = 'latin'
1208
+ (font_catalog || {}).each do |key, font_styles|
1209
+ register_font key => font_styles.map {|style, path| [style.to_sym, (font_path path)]}.to_h
1210
+ end
1211
+
1212
+ @fallback_fonts ||= []
1213
+ # FIXME read kerning setting from theme!
1214
+ default_kerning true
1215
+ end
1216
+
1217
+ # FIXME move to static method on ThemeLoader
1218
+ def font_path font_file
1219
+ # resolve relative to built-in font dir unless path is absolute
1220
+ ::File.absolute_path font_file, ThemeLoader::FontsDir
1221
+ end
1222
+
1223
+ def theme_fill_and_stroke_bounds category
1224
+ fill_and_stroke_bounds @theme[%(#{category}_background_color)], @theme[%(#{category}_border_color)], {
1225
+ line_width: @theme[%(#{category}_border_width)],
1226
+ radius: @theme[%(#{category}_border_radius)]
1227
+ }
1228
+ end
1229
+
1230
+ # Insert a top margin space unless cursor is at the top of the page.
1231
+ # Start a new page if y value is greater than remaining space on page.
1232
+ def margin_top y
1233
+ margin y, :top
1234
+ end
1235
+
1236
+ # Insert a bottom margin space unless cursor is at the top of the page (not likely).
1237
+ # Start a new page if y value is greater than remaining space on page.
1238
+ def margin_bottom y
1239
+ margin y, :bottom
1240
+ end
1241
+
1242
+ # Insert a margin space of type position unless cursor is at the top of the page.
1243
+ # Start a new page if y value is greater than remaining space on page.
1244
+ def margin y, position
1245
+ unless y == 0 || at_page_top?
1246
+ if cursor <= y
1247
+ @margin_box.move_past_bottom
1248
+ else
1249
+ move_down y
1250
+ end
1251
+ end
1252
+ end
1253
+
1254
+ # Lookup margin for theme element and position, then delegate to margin method.
1255
+ # If the margin value is not found, assume 0 for position = :top and $vertical_rhythm for position = :bottom.
1256
+ def theme_margin category, position
1257
+ margin(@theme[%(#{category}_margin_#{position})] || (position == :bottom ? @theme.vertical_rhythm : 0), position)
1258
+ end
1259
+
1260
+ def theme_font category, opts = {}
1261
+ # QUESTION should we fallback to base_font_* or just leave current setting?
1262
+ family = @theme[%(#{category}_font_family)] || @theme.base_font_family
1263
+
1264
+ if (level = opts[:level])
1265
+ size = @theme[%(#{category}_font_size_h#{level})] || @theme.base_font_size
1266
+ else
1267
+ size = @theme[%(#{category}_font_size)] || @theme.base_font_size
1268
+ end
1269
+
1270
+ style = (@theme[%(#{category}_font_style)] || :normal).to_sym
1271
+
1272
+ if level
1273
+ color = @theme[%(#{category}_font_color_h#{level})] || @theme[%(#{category}_font_color)]
1274
+ else
1275
+ color = @theme[%(#{category}_font_color)]
1276
+ end
1277
+
1278
+ if color
1279
+ prev_color = @font_color
1280
+ @font_color = color
1281
+ end
1282
+ font family, size: size, style: style do
1283
+ yield
1284
+ end
1285
+ if color
1286
+ @font_color = prev_color
1287
+ end
1288
+ end
1289
+
1290
+ # TODO document me, esp the first line formatting functionality
1291
+ def typeset_text string, line_metrics, opts = {}
1292
+ move_down line_metrics.padding_top
1293
+ opts = { leading: line_metrics.leading, final_gap: line_metrics.final_gap }.merge opts
1294
+ if (first_line_opts = opts.delete :first_line_options)
1295
+ # TODO good candidate for Prawn enhancement!
1296
+ text_with_formatted_first_line string, first_line_opts, opts
1297
+ else
1298
+ text string, opts
1299
+ end
1300
+ move_down line_metrics.padding_bottom
1301
+ end
1302
+
1303
+ # QUESTION combine with typeset_text?
1304
+ def typeset_formatted_text fragments, line_metrics, opts = {}
1305
+ move_down line_metrics.padding_top
1306
+ formatted_text fragments, { leading: line_metrics.leading, final_gap: line_metrics.final_gap }.merge(opts)
1307
+ move_down line_metrics.padding_bottom
1308
+ end
1309
+
1310
+ def height_of_typeset_text string, opts = {}
1311
+ line_metrics = (calc_line_metrics opts[:line_height] || @theme.base_line_height)
1312
+ (height_of string, leading: line_metrics.leading, final_gap: line_metrics.final_gap) + line_metrics.padding_top + line_metrics.padding_bottom
1313
+ end
1314
+
1315
+ def prepare_verbatim string
1316
+ string.gsub(BuiltInEntityCharsRx, BuiltInEntityChars)
1317
+ .gsub(IndentationRx) { NoBreakSpace * $&.length }
1318
+ end
1319
+
1320
+ # Remove all HTML tags and resolve all entities in a string
1321
+ # FIXME add option to control escaping entities, or a filter mechanism in general
1322
+ def sanitize string
1323
+ string.gsub(/<[^>]+>/, '')
1324
+ .gsub(/&#(\d{2,4});/) { [$1.to_i].pack('U*') }
1325
+ .gsub('&lt;', '<').gsub('&gt;', '>').gsub('&amp;', '&')
1326
+ .tr_s(' ', ' ')
1327
+ .strip
1328
+ end
1329
+
1330
+ def resolve_imagesdir doc
1331
+ @imagesdir ||= begin
1332
+ imagesdir = (doc.attr 'imagesdir', '.').chomp '/'
1333
+ imagesdir = imagesdir == '.' ? nil : %(#{imagesdir}/)
1334
+ end
1335
+ end
1336
+
1337
+ # QUESTION move to prawn/extensions.rb?
1338
+ def init_scratch_prototype
1339
+ # IMPORTANT don't set font before using Marshal, it causes serialization to fail
1340
+ @prototype = ::Marshal.load ::Marshal.dump self
1341
+ @prototype.state.store.info.data[:Scratch] = true
1342
+ # we're now starting a new page each time, so no need to do it here
1343
+ #@prototype.start_new_page if @prototype.page_number == 0
1344
+ end
1345
+
1346
+ =begin
1347
+ def create_stamps
1348
+ create_stamp 'masthead' do
1349
+ canvas do
1350
+ save_graphics_state do
1351
+ stroke_color '000000'
1352
+ x_margin = mm2pt 20
1353
+ y_margin = mm2pt 15
1354
+ stroke_horizontal_line x_margin, bounds.right - x_margin, at: bounds.top - y_margin
1355
+ stroke_horizontal_line x_margin, bounds.right - x_margin, at: y_margin
1356
+ end
1357
+ end
1358
+ end
1359
+
1360
+ @stamps_initialized = true
1361
+ end
1362
+ =end
1363
+ end
1364
+ end
1365
+ end