asciidoctor-pdf 1.5.0.alpha.17 → 1.5.0.beta.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.adoc +75 -2
  3. data/NOTICE.adoc +14 -11
  4. data/README.adoc +105 -27
  5. data/asciidoctor-pdf.gemspec +4 -1
  6. data/data/themes/base-theme.yml +4 -0
  7. data/data/themes/default-theme.yml +17 -34
  8. data/data/themes/default-with-fallback-font-theme.yml +22 -0
  9. data/docs/theming-guide.adoc +1057 -867
  10. data/lib/asciidoctor-pdf/asciidoctor_ext/abstract_block.rb +5 -0
  11. data/lib/asciidoctor-pdf/asciidoctor_ext/document.rb +3 -0
  12. data/lib/asciidoctor-pdf/asciidoctor_ext/image.rb +4 -4
  13. data/lib/asciidoctor-pdf/asciidoctor_ext/logging_shim.rb +8 -2
  14. data/lib/asciidoctor-pdf/asciidoctor_ext/section.rb +16 -8
  15. data/lib/asciidoctor-pdf/asciidoctor_ext.rb +3 -1
  16. data/lib/asciidoctor-pdf/converter.rb +758 -499
  17. data/lib/asciidoctor-pdf/core_ext/hash.rb +5 -0
  18. data/lib/asciidoctor-pdf/core_ext/regexp.rb +3 -0
  19. data/lib/asciidoctor-pdf/core_ext.rb +2 -0
  20. data/lib/asciidoctor-pdf/formatted_text/formatter.rb +8 -1
  21. data/lib/asciidoctor-pdf/formatted_text/inline_image_arranger.rb +3 -1
  22. data/lib/asciidoctor-pdf/formatted_text/parser.rb +24 -12
  23. data/lib/asciidoctor-pdf/formatted_text/parser.treetop +1 -1
  24. data/lib/asciidoctor-pdf/formatted_text/text_background_and_border_renderer.rb +45 -0
  25. data/lib/asciidoctor-pdf/formatted_text/transform.rb +44 -21
  26. data/lib/asciidoctor-pdf/formatted_text.rb +1 -0
  27. data/lib/asciidoctor-pdf/index_catalog.rb +9 -3
  28. data/lib/asciidoctor-pdf/measurements.rb +1 -1
  29. data/lib/asciidoctor-pdf/prawn_ext/extensions.rb +37 -21
  30. data/lib/asciidoctor-pdf/prawn_ext/images.rb +18 -7
  31. data/lib/asciidoctor-pdf/roman_numeral.rb +12 -0
  32. data/lib/asciidoctor-pdf/theme_loader.rb +99 -69
  33. data/lib/asciidoctor-pdf/version.rb +1 -1
  34. metadata +45 -5
@@ -57,6 +57,7 @@ class Converter < ::Prawn::Document
57
57
  ColumnPositions = [:left, :center, :right]
58
58
  PageLayouts = [:portrait, :landscape]
59
59
  PageSides = [:recto, :verso]
60
+ (PDFVersions = { '1.3' => 1.3, '1.4' => 1.4, '1.5' => 1.5, '1.6' => 1.6, '1.7' => 1.7 }).default = 1.4
60
61
  LF = %(\n)
61
62
  DoubleLF = %(\n\n)
62
63
  TAB = %(\t)
@@ -90,14 +91,13 @@ class Converter < ::Prawn::Document
90
91
  MeasurementRxt = '\\d+(?:\\.\\d+)?(?:in|cm|mm|p[txc])?'
91
92
  MeasurementPartsRx = /^(\d+(?:\.\d+)?)(in|mm|cm|p[txc])?$/
92
93
  PageSizeRx = /^(?:\[(#{MeasurementRxt}), ?(#{MeasurementRxt})\]|(#{MeasurementRxt})(?: x |x)(#{MeasurementRxt})|\S+)$/
93
- # CalloutExtractRx synced from /lib/asciidoctor.rb of Asciidoctor core
94
- CalloutExtractRx = /(?:(?:\/\/|#|--|;;) ?)?(\\)?<!?(--|)(\d+)\2> ?(?=(?:\\?<!?\2\d+\2> ?)*$)/
94
+ CalloutExtractRx = /(?:(?:\/\/|#|--|;;) ?)?(\\)?<!?(|--)(\d+|\.)\2> ?(?=(?:\\?<!?\2(?:\d+|\.)\2>)*$)/
95
95
  ImageAttributeValueRx = /^image:{1,2}(.*?)\[(.*?)\]$/
96
96
  UriBreakCharsRx = /(?:\/|\?|&amp;|#)(?!$)/
97
97
  UriBreakCharRepl = %(\\&#{ZeroWidthSpace})
98
98
  UriSchemeBoundaryRx = /(?<=:\/\/)/
99
99
  LineScanRx = /\n|.+/
100
- BlankLineRx = /\n[[:blank:]]*\n/
100
+ BlankLineRx = /\n{2,}/
101
101
  WhitespaceChars = %( \t\n)
102
102
  SourceHighlighters = ['coderay', 'pygments', 'rouge'].to_set
103
103
  PygmentsBgColorRx = /^\.highlight +{ *background: *#([^;]+);/
@@ -113,10 +113,10 @@ class Converter < ::Prawn::Document
113
113
  # NOTE enabling data-uri forces Asciidoctor Diagram to produce absolute image paths
114
114
  doc.attributes['data-uri'] = ((doc.instance_variable_get :@attribute_overrides) || {})['data-uri'] = ''
115
115
  end
116
- @list_numbers = []
117
- @list_bullets = []
118
116
  @capabilities = {
119
- expands_tabs: (::Asciidoctor::VERSION.start_with? '1.5.3.') || AsciidoctorVersion >= (::Gem::Version.create '1.5.3')
117
+ expands_tabs: (::Asciidoctor::VERSION.start_with? '1.5.3.') || AsciidoctorVersion >= (::Gem::Version.create '1.5.3'),
118
+ special_sectnums: AsciidoctorVersion >= (::Gem::Version.create '1.5.7'),
119
+ syntax_highlighter: AsciidoctorVersion >= (::Gem::Version.create '2.0.0'),
120
120
  }
121
121
  end
122
122
 
@@ -155,16 +155,19 @@ class Converter < ::Prawn::Document
155
155
  unless (doc.attribute_locked? 'pagenums') || ((doc.instance_variable_get :@attributes_modified).include? 'pagenums')
156
156
  doc.attributes['pagenums'] = ''
157
157
  end
158
+ if (idx_sect = doc.sections.find {|candidate| candidate.sectname == 'index' }) && idx_sect.numbered
159
+ idx_sect.numbered = false
160
+ end unless @capabilities[:special_sectnums]
158
161
  #assign_missing_section_ids doc
159
162
 
160
163
  # promote anonymous preface (defined using preamble block) to preface section
161
164
  # FIXME this should be done in core
162
- if doc.doctype == 'book' && (blk_0 = doc.blocks[0]) && blk_0.context == :preamble &&
163
- blk_0.title? && blk_0.blocks[0].style != 'abstract' && (blk_1 = doc.blocks[1]) && blk_1.context == :section
165
+ if doc.doctype == 'book' && (blk_0 = doc.blocks[0]) && blk_0.context == :preamble && blk_0.title? &&
166
+ !blk_0.title.nil_or_empty? && blk_0.blocks[0].style != 'abstract' && (blk_1 = doc.blocks[1]) && blk_1.context == :section
164
167
  preface = Section.new doc, blk_1.level, false, attributes: { 1 => 'preface', 'style' => 'preface' }
165
168
  preface.special = true
166
169
  preface.sectname = 'preface'
167
- preface.title = doc.attr 'preface-title', 'Preface'
170
+ preface.title = blk_0.instance_variable_get :@title
168
171
  # QUESTION should ID be generated from raw or converted title? core is not clear about this
169
172
  preface.id = preface.generate_id
170
173
  preface.blocks.replace blk_0.blocks.map {|b| b.parent = preface; b }
@@ -180,16 +183,14 @@ class Converter < ::Prawn::Document
180
183
  set_page_margin next_page_margin
181
184
  end
182
185
  # TODO implement as a watermark (on top)
183
- if @page_bg_image
184
- # FIXME implement fitting and centering for SVG
185
- # TODO implement image scaling (numeric value or "fit")
186
- canvas { image @page_bg_image, position: :center, fit: [bounds.width, bounds.height] }
186
+ if (bg_image = @page_bg_image[page_side])
187
+ canvas { image bg_image[0], ({ position: :center, vposition: :center }.merge bg_image[1]) }
187
188
  elsif @page_bg_color && @page_bg_color != 'FFFFFF'
188
189
  fill_absolute_bounds @page_bg_color
189
190
  end
190
191
  end if respond_to? :on_page_create
191
192
 
192
- layout_cover_page :front, doc
193
+ layout_cover_page doc, :front
193
194
  if (insert_title_page = doc.doctype == 'book' || (doc.attr? 'title-page'))
194
195
  layout_title_page doc
195
196
  # NOTE a new page will already be started if the cover image is a PDF
@@ -233,20 +234,31 @@ class Converter < ::Prawn::Document
233
234
  start_new_page if @media == 'prepress' && verso_page?
234
235
 
235
236
  if insert_title_page
236
- body_start_page_number = page_number
237
+ body_offset = (body_start_page_number = page_number) - 1
238
+ front_matter_sig = [@theme.running_content_start_at || 'body', @theme.page_numbering_start_at || 'body', insert_toc]
237
239
  # NOTE start running content from title or toc, if specified (default: body)
238
- if @theme.running_content_start_at == 'title'
239
- num_front_matter_pages = 0
240
- elsif insert_toc && @theme.running_content_start_at == 'toc'
241
- num_front_matter_pages = 1
242
- else # body
243
- num_front_matter_pages = body_start_page_number - 1
244
- end
240
+ num_front_matter_pages = {
241
+ ['title', 'title', true] => [0, 0],
242
+ ['title', 'title', false] => [0, 0],
243
+ ['title', 'toc', true] => [0, 1],
244
+ ['title', 'toc', false] => [0, 1],
245
+ ['title', 'body', true] => [0, body_offset],
246
+ ['title', 'body', false] => [0, 1],
247
+ ['toc', 'title', true] => [1, 0],
248
+ ['toc', 'title', false] => [1, 0],
249
+ ['toc', 'toc', true] => [1, 1],
250
+ ['toc', 'toc', false] => [1, 1],
251
+ ['toc', 'body', true] => [1, body_offset],
252
+ ['body', 'title', true] => [body_offset, 0],
253
+ ['body', 'title', false] => [1, 0],
254
+ ['body', 'toc', true] => [body_offset, 1],
255
+ }[front_matter_sig] || [body_offset, body_offset]
245
256
  else
246
- num_front_matter_pages = body_start_page_number - 1
257
+ # Q: what if there's only a toc page, but not title?
258
+ num_front_matter_pages = [body_start_page_number - 1] * 2
247
259
  end
248
260
 
249
- @index.start_page_number = num_front_matter_pages + 1
261
+ @index.start_page_number = num_front_matter_pages[1] + 1
250
262
  doc.set_attr 'pdf-anchor', (doc_anchor = derive_anchor_from_id doc.id, 'top')
251
263
  add_dest_for_block doc, doc_anchor
252
264
 
@@ -261,24 +273,24 @@ class Converter < ::Prawn::Document
261
273
  # QUESTION should we delete page if document is empty? (leaving no pages?)
262
274
  delete_page if page_is_empty? && page_count > 1
263
275
 
264
- toc_page_nums = insert_toc ? (layout_toc doc, num_toc_levels, toc_page_nums.first, num_front_matter_pages, toc_start) : []
276
+ toc_page_nums = insert_toc ? (layout_toc doc, num_toc_levels, toc_page_nums.first, num_front_matter_pages[1], toc_start) : []
265
277
 
266
278
  unless page_count < body_start_page_number
267
279
  unless doc.noheader || @theme.header_height.to_f.zero?
268
- layout_running_content :header, doc, skip: num_front_matter_pages
280
+ layout_running_content :header, doc, skip: num_front_matter_pages, body_start_page_number: body_start_page_number
269
281
  end
270
282
  unless doc.nofooter || @theme.footer_height.to_f.zero?
271
- layout_running_content :footer, doc, skip: num_front_matter_pages
283
+ layout_running_content :footer, doc, skip: num_front_matter_pages, body_start_page_number: body_start_page_number
272
284
  end
273
285
  end
274
286
 
275
- add_outline doc, num_toc_levels, toc_page_nums, num_front_matter_pages
287
+ add_outline doc, (doc.attr 'outlinelevels', num_toc_levels).to_i, toc_page_nums, num_front_matter_pages[1]
276
288
  # TODO allow document (or theme) to override initial view magnification
277
289
  # NOTE add 1 to page height to force initial scroll to 0; a nil value also seems to work
278
290
  catalog.data[:OpenAction] = dest_fit_horizontally((page_height + 1), state.pages[0]) if state.pages.size > 0
279
291
  catalog.data[:ViewerPreferences] = { DisplayDocTitle: true }
280
292
 
281
- layout_cover_page :back, doc
293
+ layout_cover_page doc, :back
282
294
  nil
283
295
  end
284
296
 
@@ -289,11 +301,12 @@ class Converter < ::Prawn::Document
289
301
 
290
302
  # TODO only allow method to be called once (or we need a reset)
291
303
  def init_pdf doc
292
- theme = load_theme doc
293
- pdf_opts = build_pdf_options doc, theme
294
- # QUESTION should page options be preserved (otherwise, not readily available)
304
+ @allow_uri_read = doc.attr? 'allow-uri-read'
305
+ pdf_opts = build_pdf_options doc, (theme = load_theme doc)
306
+ # QUESTION should page options be preserved? (otherwise, not readily available)
295
307
  #@page_opts = { size: pdf_opts[:page_size], layout: pdf_opts[:page_layout] }
296
308
  ::Prawn::Document.instance_method(:initialize).bind(self).call pdf_opts
309
+ renderer.min_version PDFVersions[doc.attr 'pdf-version']
297
310
  @page_margin_by_side = { recto: page_margin, verso: page_margin }
298
311
  if (@media = doc.attr 'media', 'screen') == 'prepress'
299
312
  @ppbook = doc.doctype == 'book'
@@ -311,16 +324,24 @@ class Converter < ::Prawn::Document
311
324
  end
312
325
  # QUESTION should ThemeLoader register fonts?
313
326
  register_fonts theme.font_catalog, (doc.attr 'scripts', 'latin'), (doc.attr 'pdf-fontsdir', ThemeLoader::FontsDir)
314
- if (bg_image = resolve_background_image doc, theme, 'page-background-image') && bg_image != 'none'
315
- @page_bg_image = bg_image
327
+ if (bg_image = resolve_background_image doc, theme, 'page-background-image') && bg_image[0]
328
+ @page_bg_image = { verso: bg_image, recto: bg_image }
316
329
  else
317
- @page_bg_image = nil
330
+ @page_bg_image = { verso: nil, recto: nil }
331
+ end
332
+ if (bg_image = resolve_background_image doc, theme, 'page-background-image-verso')
333
+ @page_bg_image[:verso] = bg_image[0] ? bg_image : nil
334
+ end
335
+ if (bg_image = resolve_background_image doc, theme, 'page-background-image-recto') && bg_image[0]
336
+ @page_bg_image[:recto] = bg_image[0] ? bg_image : nil
318
337
  end
319
338
  @page_bg_color = resolve_theme_color :page_background_color, 'FFFFFF'
320
339
  @fallback_fonts = [*theme.font_fallbacks]
321
340
  @font_color = theme.base_font_color
322
- @base_align = (align = doc.attr 'text-alignment') && (TextAlignmentNames.include? align) ? align : theme.base_align
341
+ @base_align = (align = doc.attr 'text-align') && (TextAlignmentNames.include? align) ? align : theme.base_align
323
342
  @text_transform = nil
343
+ @list_numerals = []
344
+ @list_bullets = []
324
345
  @footnotes = []
325
346
  @index = IndexCatalog.new
326
347
  # NOTE we have to init Pdfmark class here while we have reference to the doc
@@ -330,7 +351,16 @@ class Converter < ::Prawn::Document
330
351
  end
331
352
 
332
353
  def load_theme doc
333
- @theme ||= doc.options[:pdf_theme] || ThemeLoader.load_theme((doc.attr 'pdf-style'), (doc.attr 'pdf-stylesdir'))
354
+ @theme ||= begin
355
+ if (theme = doc.options[:pdf_theme])
356
+ @themesdir = theme.__dir__ || (doc.attr 'pdf-themesdir') || (doc.attr 'pdf-stylesdir')
357
+ else
358
+ theme_name = (doc.attr 'pdf-theme') || (doc.attr 'pdf-style')
359
+ theme = ThemeLoader.load_theme theme_name, ((doc.attr 'pdf-themesdir') || (doc.attr 'pdf-stylesdir'))
360
+ @themesdir = theme.__dir__
361
+ end
362
+ theme
363
+ end
334
364
  end
335
365
 
336
366
  def build_pdf_options doc, theme
@@ -359,19 +389,19 @@ class Converter < ::Prawn::Document
359
389
  page_margin = nil
360
390
  end
361
391
 
362
- page_size = if (doc.attr? 'pdf-page-size') && (m = PageSizeRx.match(doc.attr 'pdf-page-size'))
392
+ if (doc.attr? 'pdf-page-size') && PageSizeRx =~ (doc.attr 'pdf-page-size')
363
393
  # e.g, [8.5in, 11in]
364
- if m[1]
365
- [m[1], m[2]]
394
+ if $1
395
+ page_size = [$1, $2]
366
396
  # e.g, 8.5in x 11in
367
- elsif m[3]
368
- [m[3], m[4]]
397
+ elsif $3
398
+ page_size = [$3, $4]
369
399
  # e.g, A4
370
400
  else
371
- m[0]
401
+ page_size = $&
372
402
  end
373
403
  else
374
- theme.page_size
404
+ page_size = theme.page_size
375
405
  end
376
406
 
377
407
  page_size = case page_size
@@ -388,9 +418,9 @@ class Converter < ::Prawn::Document
388
418
  if ::Numeric === dim
389
419
  # dimension cannot be less than 0
390
420
  dim > 0 ? dim : break
391
- elsif ::String === dim && (m = (MeasurementPartsRx.match dim))
421
+ elsif ::String === dim && MeasurementPartsRx =~ dim
392
422
  # NOTE truncate to max precision retained by PDF::Core
393
- (to_pt m[1].to_f, m[2]).truncate 4
423
+ (to_pt $1.to_f, $2).truncate 4
394
424
  else
395
425
  break
396
426
  end
@@ -519,6 +549,9 @@ class Converter < ::Prawn::Document
519
549
  end
520
550
  theme_font :abstract do
521
551
  prose_opts = { line_height: @theme.abstract_line_height, align: (@theme.abstract_align || @base_align).to_sym }
552
+ if (text_indent = @theme.prose_text_indent)
553
+ prose_opts[:indent_paragraphs] = text_indent
554
+ end
522
555
  # FIXME control more first_line_options using theme
523
556
  if (line1_font_style = @theme.abstract_first_line_font_style) && line1_font_style.to_sym != font_style
524
557
  prose_opts[:first_line_options] = { styles: [font_style, line1_font_style.to_sym] }
@@ -547,21 +580,25 @@ class Converter < ::Prawn::Document
547
580
 
548
581
  def convert_preamble node
549
582
  # TODO find_by needs to support a depth argument
550
- if (first_p = (node.find_by context: :paragraph)[0]) && first_p.parent == node
583
+ # FIXME core should not be promoting paragraph to preamble if there are no sections
584
+ if (first_p = (node.find_by context: :paragraph)[0]) && first_p.parent == node && node.document.sections?
551
585
  first_p.add_role 'lead'
552
586
  end
553
587
  convert_content_for_block node
554
588
  end
555
589
 
556
- # TODO add prose around image logic (use role to add special logic for headshot)
557
590
  def convert_paragraph node
558
591
  add_dest_for_block node if node.id
559
- prose_opts = {}
592
+ prose_opts = { margin_bottom: 0 }
560
593
  lead = (roles = node.roles).include? 'lead'
561
594
  if (align = resolve_alignment_from_role roles)
562
595
  prose_opts[:align] = align
563
596
  end
564
597
 
598
+ if (text_indent = @theme.prose_text_indent)
599
+ prose_opts[:indent_paragraphs] = text_indent
600
+ end
601
+
565
602
  # TODO check if we're within one line of the bottom of the page
566
603
  # and advance to the next page if so (similar to logic for section titles)
567
604
  layout_caption node.title if node.title?
@@ -573,6 +610,14 @@ class Converter < ::Prawn::Document
573
610
  else
574
611
  layout_prose node.content, prose_opts
575
612
  end
613
+
614
+ if (margin_inner_val = @theme.prose_margin_inner) &&
615
+ (next_block = (siblings = node.parent.blocks)[(siblings.index node) + 1]) && next_block.context == :paragraph
616
+ margin_bottom_val = margin_inner_val
617
+ else
618
+ margin_bottom_val = @theme.prose_margin_bottom
619
+ end
620
+ margin_bottom margin_bottom_val
576
621
  end
577
622
 
578
623
  def convert_admonition node
@@ -607,7 +652,7 @@ class Converter < ::Prawn::Document
607
652
  label_text = node.caption
608
653
  theme_font :admonition_label do
609
654
  theme_font %(admonition_label_#{type}) do
610
- if (transform = @text_transform) && transform != 'none'
655
+ if (transform = @text_transform)
611
656
  label_text = transform_text label_text, transform
612
657
  end
613
658
  label_width = rendered_width_of_string label_text
@@ -627,6 +672,7 @@ class Converter < ::Prawn::Document
627
672
  shift_top = shift_base / 3.0
628
673
  shift_bottom = (shift_base * 2) / 3.0
629
674
  keep_together do |box_height = nil|
675
+ push_scratch doc if scratch?
630
676
  pad_box [0, cpad[1], 0, lpad[3]] do
631
677
  if box_height
632
678
  if (rule_color = @theme.admonition_column_rule_color) &&
@@ -634,7 +680,7 @@ class Converter < ::Prawn::Document
634
680
  float do
635
681
  bounding_box [0, cursor], width: label_width + lpad[1], height: box_height do
636
682
  stroke_vertical_rule rule_color,
637
- at: bounds.width,
683
+ at: bounds.right,
638
684
  line_style: (@theme.admonition_column_rule_style || :solid).to_sym,
639
685
  line_width: rule_width
640
686
  end
@@ -658,15 +704,15 @@ class Converter < ::Prawn::Document
658
704
  color: icon_data[:stroke_color],
659
705
  size: icon_size
660
706
  elsif icons
661
- if icon_path.end_with? '.svg'
707
+ if (::Asciidoctor::Image.format icon_path) == 'svg'
662
708
  begin
663
709
  svg_obj = ::Prawn::SVG::Interface.new ::File.read(icon_path), self,
664
- position: label_align,
710
+ position: label_align,
665
711
  vposition: label_valign,
666
712
  width: label_width,
667
713
  height: box_height,
668
714
  fallback_font_name: default_svg_font,
669
- enable_web_requests: (doc.attr? 'allow-uri-read'),
715
+ enable_web_requests: allow_uri_read,
670
716
  enable_file_requests_with_root: (::File.dirname icon_path)
671
717
  if (icon_height = (svg_size = svg_obj.document.sizing).output_height) > box_height
672
718
  icon_width = (svg_obj.resize height: (icon_height = box_height)).output_width
@@ -728,6 +774,7 @@ class Converter < ::Prawn::Document
728
774
  move_up shift_bottom unless at_page_top?
729
775
  end
730
776
  end
777
+ pop_scratch doc if scratch?
731
778
  end
732
779
  theme_margin :block, :bottom
733
780
  end
@@ -736,6 +783,7 @@ class Converter < ::Prawn::Document
736
783
  add_dest_for_block node if node.id
737
784
  theme_margin :block, :top
738
785
  keep_together do |box_height = nil|
786
+ push_scratch node.document if scratch?
739
787
  caption_height = node.title? ? (layout_caption node) : 0
740
788
  if box_height
741
789
  float do
@@ -749,6 +797,7 @@ class Converter < ::Prawn::Document
749
797
  convert_content_for_block node
750
798
  end
751
799
  end
800
+ pop_scratch node.document if scratch?
752
801
  end
753
802
  theme_margin :block, :bottom
754
803
  end
@@ -773,6 +822,7 @@ class Converter < ::Prawn::Document
773
822
  b_width = @theme.blockquote_border_width
774
823
  b_color = @theme.blockquote_border_color
775
824
  keep_together do |box_height = nil|
825
+ push_scratch node.document if scratch?
776
826
  start_page_number = page_number
777
827
  start_cursor = cursor
778
828
  caption_height = node.title? ? (layout_caption node) : 0
@@ -823,6 +873,7 @@ class Converter < ::Prawn::Document
823
873
  end unless b_height == 0
824
874
  end
825
875
  end
876
+ pop_scratch node.document if scratch?
826
877
  end
827
878
  theme_margin :block, :bottom
828
879
  end
@@ -834,6 +885,7 @@ class Converter < ::Prawn::Document
834
885
  add_dest_for_block node if node.id
835
886
  theme_margin :block, :top
836
887
  keep_together do |box_height = nil|
888
+ push_scratch node.document if scratch?
837
889
  if box_height
838
890
  # FIXME due to the calculation error logged in #789, we must advance page even when content is split across pages
839
891
  advance_page if box_height > cursor && !at_page_top?
@@ -890,6 +942,7 @@ class Converter < ::Prawn::Document
890
942
  convert_content_for_block node
891
943
  end
892
944
  end
945
+ pop_scratch node.document if scratch?
893
946
  end
894
947
  theme_margin :block, :bottom
895
948
  end
@@ -908,10 +961,10 @@ class Converter < ::Prawn::Document
908
961
  end
909
962
  end
910
963
  add_dest_for_block node if node.id
911
- @list_numbers ||= []
964
+ @list_numerals ||= []
912
965
  # FIXME move \u2460 to constant (or theme setting)
913
966
  # \u2460 = circled one, \u24f5 = double circled one, \u278b = negative circled one
914
- @list_numbers << %(\u2460)
967
+ @list_numerals << %(\u2460)
915
968
  #stroke_horizontal_rule @theme.caption_border_bottom_color
916
969
  line_metrics = calc_line_metrics @theme.base_line_height
917
970
  node.items.each_with_index do |item, idx|
@@ -919,7 +972,7 @@ class Converter < ::Prawn::Document
919
972
  advance_page if cursor < (line_metrics.height + line_metrics.leading + line_metrics.padding_top) + 1
920
973
  convert_colist_item item
921
974
  end
922
- @list_numbers.pop
975
+ @list_numerals.pop
923
976
  # correct bottom margin of last item
924
977
  list_margin_bottom = @theme.prose_margin_bottom
925
978
  margin_bottom list_margin_bottom - @theme.outline_list_item_spacing
@@ -931,7 +984,7 @@ class Converter < ::Prawn::Document
931
984
  marker_width = rendered_width_of_string %(#{conum_glyph 1}x)
932
985
  float do
933
986
  bounding_box [0, cursor], width: marker_width do
934
- @list_numbers << (index = @list_numbers.pop).next
987
+ @list_numerals << (index = @list_numerals.pop).next
935
988
  theme_font :conum do
936
989
  layout_prose index, align: :center, line_height: @theme.conum_line_height, inline_format: false, margin: 0
937
990
  end
@@ -940,39 +993,46 @@ class Converter < ::Prawn::Document
940
993
  end
941
994
 
942
995
  indent marker_width do
943
- convert_content_for_list_item node, margin_bottom: @theme.outline_list_item_spacing
996
+ convert_content_for_list_item node, :colist, margin_bottom: @theme.outline_list_item_spacing
944
997
  end
945
998
  end
946
999
 
947
1000
  def convert_dlist node
948
1001
  add_dest_for_block node if node.id
949
1002
 
950
- # TODO check if we're within one line of the bottom of the page
951
- # and advance to the next page if so (similar to logic for section titles)
952
- layout_caption node.title if node.title?
1003
+ case node.style
1004
+ when 'qanda'
1005
+ (@list_numerals ||= []) << '1'
1006
+ convert_outline_list node
1007
+ @list_numerals.pop
1008
+ else
1009
+ # TODO check if we're within one line of the bottom of the page
1010
+ # and advance to the next page if so (similar to logic for section titles)
1011
+ layout_caption node.title if node.title?
953
1012
 
954
- node.items.each do |terms, desc|
955
- terms = [*terms]
956
- # NOTE don't orphan the terms, allow for at least one line of content
957
- # FIXME extract ensure_space (or similar) method
958
- advance_page if cursor < @theme.base_line_height_length * (terms.size + 1)
959
- terms.each do |term|
960
- # FIXME layout_prose should pass style downward when parsing formatted text
961
- #layout_prose term.text, style: @theme.description_list_term_font_style.to_sym, margin_top: 0, margin_bottom: @theme.description_list_term_spacing, align: :left
962
- term_text = term.text
963
- case @theme.description_list_term_font_style.to_sym
964
- when :bold
965
- term_text = %(<strong>#{term_text}</strong>)
966
- when :italic
967
- term_text = %(<em>#{term_text}</em>)
968
- when :bold_italic
969
- term_text = %(<strong><em>#{term_text}</em></strong>)
970
- end
971
- layout_prose term_text, margin_top: 0, margin_bottom: @theme.description_list_term_spacing, align: :left
972
- end
973
- if desc
974
- indent @theme.description_list_description_indent do
975
- convert_content_for_list_item desc
1013
+ node.items.each do |terms, desc|
1014
+ terms = [*terms]
1015
+ # NOTE don't orphan the terms, allow for at least one line of content
1016
+ # FIXME extract ensure_space (or similar) method
1017
+ advance_page if cursor < @theme.base_line_height_length * (terms.size + 1)
1018
+ terms.each do |term|
1019
+ # FIXME layout_prose should pass style downward when parsing formatted text
1020
+ #layout_prose term.text, style: @theme.description_list_term_font_style.to_sym, margin_top: 0, margin_bottom: @theme.description_list_term_spacing, align: :left
1021
+ term_text = term.text
1022
+ case @theme.description_list_term_font_style.to_sym
1023
+ when :bold
1024
+ term_text = %(<strong>#{term_text}</strong>)
1025
+ when :italic
1026
+ term_text = %(<em>#{term_text}</em>)
1027
+ when :bold_italic
1028
+ term_text = %(<strong><em>#{term_text}</em></strong>)
1029
+ end
1030
+ layout_prose term_text, margin_top: 0, margin_bottom: @theme.description_list_term_spacing, align: :left
1031
+ end
1032
+ if desc
1033
+ indent @theme.description_list_description_indent do
1034
+ convert_content_for_list_item desc, :dlist_desc
1035
+ end
976
1036
  end
977
1037
  end
978
1038
  end
@@ -980,11 +1040,11 @@ class Converter < ::Prawn::Document
980
1040
 
981
1041
  def convert_olist node
982
1042
  add_dest_for_block node if node.id
983
- @list_numbers ||= []
984
- # TODO move list_number resolve to a method
985
- list_number = case node.style
1043
+ @list_numerals ||= []
1044
+ # TODO move list_numeral resolve to a method
1045
+ list_numeral = case node.style
986
1046
  when 'arabic'
987
- '1'
1047
+ 1
988
1048
  when 'decimal'
989
1049
  '01'
990
1050
  when 'loweralpha'
@@ -997,16 +1057,24 @@ class Converter < ::Prawn::Document
997
1057
  RomanNumeral.new 'I'
998
1058
  when 'lowergreek'
999
1059
  LowercaseGreekA
1060
+ when 'unstyled', 'unnumbered', 'no-bullet'
1061
+ nil
1062
+ when 'none'
1063
+ ''
1000
1064
  else
1001
- '1'
1065
+ 1
1002
1066
  end
1003
- # TODO support start values < 1 (issue #498)
1004
- if (start = ((node.attr 'start', nil, false) || ((node.option? 'reversed') ? node.items.size : 1)).to_i) > 1
1005
- (start - 1).times { list_number = list_number.next }
1067
+ if list_numeral && list_numeral != '' &&
1068
+ (start = (node.attr 'start', nil, false) || ((node.option? 'reversed') ? node.items.size : nil))
1069
+ if (start = start.to_i) > 1
1070
+ (start - 1).times { list_numeral = list_numeral.next }
1071
+ elsif start < 1 && !(::String === list_numeral)
1072
+ (start - 1).abs.times { list_numeral = list_numeral.pred }
1073
+ end
1006
1074
  end
1007
- @list_numbers << list_number
1075
+ @list_numerals << list_numeral
1008
1076
  convert_outline_list node
1009
- @list_numbers.pop
1077
+ @list_numerals.pop
1010
1078
  end
1011
1079
 
1012
1080
  def convert_ulist node
@@ -1064,13 +1132,13 @@ class Converter < ::Prawn::Document
1064
1132
  complex = false
1065
1133
  # ...or if we want to give all items in the list the same treatment
1066
1134
  #complex = node.items.find(&:complex?) ? true : false
1067
- if node.context == :ulist && !@list_bullets[-1]
1135
+ if (node.context == :ulist && !@list_bullets[-1]) || (node.context == :olist && !@list_numerals[-1])
1068
1136
  if node.style == 'unstyled'
1069
1137
  # unstyled takes away all indentation
1070
1138
  list_indent = 0
1071
1139
  elsif (list_indent = @theme.outline_list_indent) > 0
1072
1140
  # no-bullet aligns text with left-hand side of bullet position (as though there's no bullet)
1073
- list_indent = [list_indent - (rendered_width_of_string %(\u2022x)), 0].max
1141
+ list_indent = [list_indent - (rendered_width_of_string %(#{node.context == :ulist ? "\u2022" : '1.'}x)), 0].max
1074
1142
  end
1075
1143
  else
1076
1144
  list_indent = @theme.outline_list_indent
@@ -1079,7 +1147,7 @@ class Converter < ::Prawn::Document
1079
1147
  node.items.each do |item|
1080
1148
  # FIXME extract to an ensure_space (or similar) method; simplify
1081
1149
  advance_page if cursor < (line_metrics.height + line_metrics.leading + line_metrics.padding_top)
1082
- convert_outline_list_item item, item.complex?, opts
1150
+ convert_outline_list_item item, node, opts
1083
1151
  end
1084
1152
  end
1085
1153
  # NOTE Children will provide the necessary bottom margin if last item is complex.
@@ -1091,33 +1159,48 @@ class Converter < ::Prawn::Document
1091
1159
  end
1092
1160
  end
1093
1161
 
1094
- def convert_outline_list_item node, complex = false, opts = {}
1162
+ def convert_outline_list_item node, list, opts = {}
1095
1163
  # TODO move this to a draw_bullet (or draw_marker) method
1096
1164
  marker_style = {}
1097
1165
  marker_style[:font_color] = @theme.outline_list_marker_font_color || @font_color
1098
1166
  marker_style[:font_family] = font_family
1099
1167
  marker_style[:font_size] = font_size
1100
1168
  marker_style[:line_height] = @theme.base_line_height
1101
- case (list_type = node.parent.context)
1169
+ case (list_type = list.context)
1102
1170
  when :ulist
1103
- marker_type = @list_bullets[-1]
1104
- if marker_type == :checkbox
1105
- # QUESTION should we remove marker indent if not a checkbox?
1106
- if node.attr? 'checkbox', nil, false
1107
- marker_type = (node.attr? 'checked', nil, false) ? :checked : :unchecked
1108
- marker = @theme[%(ulist_marker_#{marker_type}_content)] || BallotBox[marker_type]
1171
+ complex = node.complex?
1172
+ if (marker_type = @list_bullets[-1])
1173
+ if marker_type == :checkbox
1174
+ # QUESTION should we remove marker indent if not a checkbox?
1175
+ if node.attr? 'checkbox', nil, false
1176
+ marker_type = (node.attr? 'checked', nil, false) ? :checked : :unchecked
1177
+ marker = @theme[%(ulist_marker_#{marker_type}_content)] || BallotBox[marker_type]
1178
+ end
1179
+ else
1180
+ marker = @theme[%(ulist_marker_#{marker_type}_content)] || Bullets[marker_type]
1109
1181
  end
1110
- else
1111
- marker = @theme[%(ulist_marker_#{marker_type}_content)] || Bullets[marker_type]
1182
+ [:font_color, :font_family, :font_size, :line_height].each do |prop|
1183
+ marker_style[prop] = @theme[%(ulist_marker_#{marker_type}_#{prop})] || @theme[%(ulist_marker_#{prop})] || marker_style[prop]
1184
+ end if marker
1112
1185
  end
1113
- [:font_color, :font_family, :font_size, :line_height].each do |prop|
1114
- marker_style[prop] = @theme[%(ulist_marker_#{marker_type}_#{prop})] || @theme[%(ulist_marker_#{prop})] || marker_style[prop]
1115
- end if marker
1116
1186
  when :olist
1117
- dir = (node.parent.option? 'reversed') ? :pred : :next
1118
- @list_numbers << ((index = @list_numbers.pop).public_send dir)
1187
+ complex = node.complex?
1188
+ if (index = @list_numerals.pop)
1189
+ if index == ''
1190
+ marker = ''
1191
+ else
1192
+ marker = %(#{index}.)
1193
+ dir = (node.parent.option? 'reversed') ? :pred : :next
1194
+ @list_numerals << (index = index.public_send dir)
1195
+ end
1196
+ end
1197
+ when :dlist
1198
+ # NOTE list.style is 'qanda'
1199
+ complex = node[1] && node[1].complex?
1200
+ @list_numerals << (index = @list_numerals.pop).next
1119
1201
  marker = %(#{index}.)
1120
1202
  else
1203
+ complex = node.complex?
1121
1204
  logger.warn %(unknown list type #{list_type.inspect})
1122
1205
  marker = @theme.ulist_marker_disc_content || Bullets[:disc]
1123
1206
  end
@@ -1148,15 +1231,24 @@ class Converter < ::Prawn::Document
1148
1231
  end
1149
1232
 
1150
1233
  if complex
1151
- convert_content_for_list_item node, opts
1234
+ convert_content_for_list_item node, list_type, opts
1152
1235
  else
1153
- convert_content_for_list_item node, (opts.merge margin_bottom: @theme.outline_list_item_spacing)
1236
+ convert_content_for_list_item node, list_type, (opts.merge margin_bottom: @theme.outline_list_item_spacing)
1154
1237
  end
1155
1238
  end
1156
1239
 
1157
- def convert_content_for_list_item node, opts = {}
1158
- layout_prose node.text, opts if node.text?
1159
- convert_content_for_block node
1240
+ def convert_content_for_list_item node, list_type, opts = {}
1241
+ if list_type == :dlist # qanda
1242
+ terms, desc = node
1243
+ [*terms].each {|term| layout_prose %(<em>#{term.text}</em>), opts }
1244
+ if desc
1245
+ layout_prose desc.text, opts if desc.text?
1246
+ convert_content_for_block desc
1247
+ end
1248
+ else
1249
+ layout_prose node.text, opts if node.text?
1250
+ convert_content_for_block node
1251
+ end
1160
1252
  end
1161
1253
 
1162
1254
  def convert_image node, opts = {}
@@ -1165,19 +1257,25 @@ class Converter < ::Prawn::Document
1165
1257
 
1166
1258
  if image_format == 'gif' && !(defined? ::GMagick::Image)
1167
1259
  logger.warn %(GIF image format not supported. Install the prawn-gmagick gem or convert #{target} to PNG.) unless scratch?
1168
- image_path = false
1260
+ image_path = nil
1169
1261
  elsif ::Base64 === target
1170
1262
  image_path = target
1171
- elsif (image_path = resolve_image_path node, target, (opts.fetch :relative_to_imagesdir, true), image_format) &&
1172
- (::File.readable? image_path)
1173
- # NOTE import_page automatically advances to next page afterwards
1174
- # QUESTION should we add destination to top of imported page?
1175
- return import_page image_path, replace: page_is_empty? if image_format == 'pdf'
1176
- else
1177
- logger.warn %(image to embed not found or not readable: #{image_path || target}) unless scratch?
1263
+ elsif (image_path = resolve_image_path node, target, (opts.fetch :relative_to_imagesdir, true), image_format)
1264
+ if ::File.readable? image_path
1265
+ # NOTE import_page automatically advances to next page afterwards
1266
+ # QUESTION should we add destination to top of imported page?
1267
+ return import_page image_path, replace: page_is_empty? if image_format == 'pdf'
1268
+ elsif image_format == 'pdf'
1269
+ logger.warn %(pdf to insert not found or not readable: #{image_path}) unless scratch?
1270
+ # QUESTION should we use alt text in this case?
1271
+ return
1272
+ else
1273
+ logger.warn %(image to embed not found or not readable: #{image_path}) unless scratch?
1274
+ image_path = nil
1275
+ end
1276
+ elsif image_format == 'pdf'
1178
1277
  # QUESTION should we use alt text in this case?
1179
- return if image_format == 'pdf'
1180
- image_path = false
1278
+ return
1181
1279
  end
1182
1280
 
1183
1281
  theme_margin :block, :top unless (pinned = opts[:pinned])
@@ -1193,7 +1291,7 @@ class Converter < ::Prawn::Document
1193
1291
  end if node.title?
1194
1292
 
1195
1293
  # TODO support cover (aka canvas) image layout using "canvas" (or "cover") role
1196
- width = resolve_explicit_width node.attributes, (available_w = bounds.width), support_vw: true, use_fallback: true
1294
+ width = resolve_explicit_width node.attributes, (available_w = bounds.width), support_vw: true, use_fallback: true, constrain_to_bounds: true
1197
1295
  # TODO add `to_pt page_width` method to ViewportWidth type
1198
1296
  width = (width.to_f / 100) * page_width if ViewportWidth === width
1199
1297
 
@@ -1214,8 +1312,7 @@ class Converter < ::Prawn::Document
1214
1312
  position: alignment,
1215
1313
  width: width,
1216
1314
  fallback_font_name: default_svg_font,
1217
- enable_web_requests: (node.document.attr? 'allow-uri-read'),
1218
- # TODO enforce jail in safe mode
1315
+ enable_web_requests: allow_uri_read,
1219
1316
  enable_file_requests_with_root: file_request_root
1220
1317
  rendered_w = (svg_size = svg_obj.document.sizing).output_width
1221
1318
  if !width && (svg_obj.document.root.attributes.key? 'width')
@@ -1319,21 +1416,19 @@ class Converter < ::Prawn::Document
1319
1416
  when 'youtube'
1320
1417
  video_path = %(https://www.youtube.com/watch?v=#{video_id = node.attr 'target'})
1321
1418
  # see http://stackoverflow.com/questions/2068344/how-do-i-get-a-youtube-video-thumbnail-from-the-youtube-api
1322
- poster = (node.document.attr? 'allow-uri-read') ? %(https://img.youtube.com/vi/#{video_id}/maxresdefault.jpg) : nil
1419
+ poster = allow_uri_read ? %(https://img.youtube.com/vi/#{video_id}/maxresdefault.jpg) : nil
1323
1420
  type = 'YouTube video'
1324
1421
  when 'vimeo'
1325
1422
  video_path = %(https://vimeo.com/#{video_id = node.attr 'target'})
1326
- if node.document.attr? 'allow-uri-read'
1423
+ if allow_uri_read
1327
1424
  if node.document.attr? 'cache-uri'
1328
1425
  Helpers.require_library 'open-uri/cached', 'open-uri-cached' unless defined? ::OpenURI::Cache
1329
1426
  else
1330
1427
  ::OpenURI
1331
1428
  end
1332
1429
  poster = open(%(http://vimeo.com/api/v2/video/#{video_id}.xml), 'r') do |f|
1333
- (/<thumbnail_large>(.*?)<\/thumbnail_large>/.match f.read)[1]
1430
+ /<thumbnail_large>(.*?)<\/thumbnail_large>/ =~ f.read && $1
1334
1431
  end
1335
- else
1336
- poster = nil
1337
1432
  end
1338
1433
  type = 'Vimeo video'
1339
1434
  else
@@ -1366,13 +1461,27 @@ class Converter < ::Prawn::Document
1366
1461
 
1367
1462
  # HACK disable built-in syntax highlighter; must be done before calling node.content!
1368
1463
  if node.style == 'source' && node.attributes['language'] &&
1369
- (highlighter = node.document.attributes['source-highlighter']) &&
1370
- (SourceHighlighters.include? highlighter)
1464
+ (highlighter = node.document.attributes['source-highlighter']) && (SourceHighlighters.include? highlighter) &&
1465
+ (@capabilities[:syntax_highlighter] ? node.document.syntax_highlighter.highlight? : true)
1466
+ case highlighter
1467
+ when 'coderay'
1468
+ unless defined? ::Asciidoctor::Prawn::CodeRayEncoder
1469
+ highlighter = nil if (Helpers.require_library CodeRayRequirePath, 'coderay', :warn).nil?
1470
+ end
1471
+ when 'pygments'
1472
+ unless defined? ::Pygments
1473
+ highlighter = nil if (Helpers.require_library 'pygments', 'pygments.rb', :warn).nil?
1474
+ end
1475
+ when 'rouge'
1476
+ unless defined? ::Rouge::Formatters::Prawn
1477
+ highlighter = nil if (Helpers.require_library RougeRequirePath, 'rouge', :warn).nil?
1478
+ end
1479
+ end
1371
1480
  prev_subs = (subs = node.subs).dup
1372
- # NOTE the highlight sub is only set for coderay and pygments atm
1481
+ # NOTE the highlight sub is only set for coderay, rouge, and pygments atm
1373
1482
  highlight_idx = subs.index :highlight
1374
1483
  # NOTE scratch? here only applies if listing block is nested inside another block
1375
- if scratch?
1484
+ if !highlighter || scratch?
1376
1485
  highlighter = nil
1377
1486
  if highlight_idx
1378
1487
  # switch the :highlight sub back to :specialcharacters
@@ -1401,7 +1510,6 @@ class Converter < ::Prawn::Document
1401
1510
 
1402
1511
  source_chunks = case highlighter
1403
1512
  when 'coderay'
1404
- Helpers.require_library CodeRayRequirePath, 'coderay' unless defined? ::Asciidoctor::Prawn::CodeRayEncoder
1405
1513
  source_string, conum_mapping = extract_conums source_string
1406
1514
  srclang = node.attr 'language', 'text', false
1407
1515
  begin
@@ -1412,7 +1520,6 @@ class Converter < ::Prawn::Document
1412
1520
  fragments = (::CodeRay.scan source_string, srclang).to_prawn
1413
1521
  conum_mapping ? (restore_conums fragments, conum_mapping) : fragments
1414
1522
  when 'pygments'
1415
- Helpers.require_library 'pygments', 'pygments.rb' unless defined? ::Pygments
1416
1523
  lexer = ::Pygments::Lexer.find_by_alias(node.attr 'language', 'text', false) || ::Pygments::Lexer.find_by_mimetype('text/plain')
1417
1524
  lexer_opts = {
1418
1525
  nowrap: true,
@@ -1451,15 +1558,24 @@ class Converter < ::Prawn::Document
1451
1558
  fragments = restore_conums fragments, conum_mapping, num_trailing_spaces, linenums if conum_mapping
1452
1559
  fragments = guard_indentation fragments
1453
1560
  when 'rouge'
1454
- Helpers.require_library RougeRequirePath, 'rouge' unless defined? ::Rouge::Formatters::Prawn
1455
- lexer = ::Rouge::Lexer.find(node.attr 'language', 'text', false) || ::Rouge::Lexers::PlainText
1456
- lexer_opts = lexer.tag == 'php' ? { start_inline: !(node.option? 'mixed') } : {}
1561
+ if (srclang = node.attr 'language', nil, false)
1562
+ if srclang.include? '?'
1563
+ if (lexer = ::Rouge::Lexer.find_fancy srclang)
1564
+ unless lexer.tag != 'php' || (node.option? 'mixed') || ((lexer_opts = lexer.options).key? 'start_inline')
1565
+ lexer = lexer.class.new lexer_opts.merge 'start_inline' => true
1566
+ end
1567
+ end
1568
+ elsif (lexer = ::Rouge::Lexer.find srclang)
1569
+ lexer = lexer.new start_inline: true if lexer.tag == 'php' && !(node.option? 'mixed')
1570
+ end
1571
+ end
1572
+ lexer ||= ::Rouge::Lexers::PlainText
1457
1573
  formatter = (@rouge_formatter ||= ::Rouge::Formatters::Prawn.new theme: (node.document.attr 'rouge-style'), line_gap: @theme.code_line_gap)
1458
1574
  formatter_opts = (node.attr? 'linenums') ? { line_numbers: true, start_line: (node.attr 'start', 1, false).to_i } : {}
1459
1575
  # QUESTION allow border color to be set by theme for highlighted block?
1460
1576
  bg_color_override = formatter.background_color
1461
1577
  source_string, conum_mapping = extract_conums source_string
1462
- fragments = formatter.format((lexer.lex source_string, lexer_opts), formatter_opts)
1578
+ fragments = formatter.format((lexer.lex source_string), formatter_opts)
1463
1579
  # NOTE cleanup trailing endline (handled in rouge_ext/formatters/prawn instead)
1464
1580
  #fragments[-1][:text] == LF ? fragments.pop : fragments[-1][:text].chop!
1465
1581
  conum_mapping ? (restore_conums fragments, conum_mapping) : fragments
@@ -1533,15 +1649,16 @@ class Converter < ::Prawn::Document
1533
1649
  # and the mapping of lines to conums as the second.
1534
1650
  def extract_conums string
1535
1651
  conum_mapping = {}
1652
+ auto_num = 0
1536
1653
  string = string.split(LF).map.with_index {|line, line_num|
1537
1654
  # FIXME we get extra spaces before numbers if more than one on a line
1538
1655
  if line.include? '<'
1539
1656
  line.gsub(CalloutExtractRx) {
1540
1657
  # honor the escape
1541
- if $1 == '\\'
1542
- $&.sub '\\', ''
1658
+ if $1 == ?\\
1659
+ $&.sub $1, ''
1543
1660
  else
1544
- (conum_mapping[line_num] ||= []) << $3.to_i
1661
+ (conum_mapping[line_num] ||= []) << ($3 == '.' ? (auto_num += 1) : $3.to_i)
1545
1662
  ''
1546
1663
  end
1547
1664
  }
@@ -1644,9 +1761,7 @@ class Converter < ::Prawn::Document
1644
1761
  table_data = []
1645
1762
  node.rows[:head].each do |row|
1646
1763
  table_header = true
1647
- if (head_transform = theme.table_head_text_transform)
1648
- head_transform = nil if head_transform == 'none'
1649
- end
1764
+ head_transform = resolve_text_transform :table_head_text_transform, nil
1650
1765
  row_data = []
1651
1766
  row.each do |cell|
1652
1767
  row_data << {
@@ -1660,7 +1775,8 @@ class Converter < ::Prawn::Document
1660
1775
  colspan: cell.colspan || 1,
1661
1776
  rowspan: cell.rowspan || 1,
1662
1777
  align: (cell.attr 'halign', nil, false).to_sym,
1663
- valign: (val = cell.attr 'valign', nil, false) == 'middle' ? :center : val.to_sym
1778
+ valign: (val = cell.attr 'valign', nil, false) == 'middle' ? :center : val.to_sym,
1779
+ padding: theme.table_head_cell_padding || theme.table_cell_padding,
1664
1780
  }
1665
1781
  end
1666
1782
  table_data << row_data
@@ -1708,9 +1824,7 @@ class Converter < ::Prawn::Document
1708
1824
  end
1709
1825
  end
1710
1826
  header_cell_data = header_cell_data_cache.dup
1711
- if (cell_transform = header_cell_data.delete :text_transform) == 'none'
1712
- cell_transform = nil
1713
- end
1827
+ cell_transform = resolve_text_transform header_cell_data, nil
1714
1828
  cell_data.update header_cell_data unless header_cell_data.empty?
1715
1829
  cell_line_metrics = calc_line_metrics theme.base_line_height
1716
1830
  when :monospaced
@@ -1748,7 +1862,9 @@ class Converter < ::Prawn::Document
1748
1862
  cell_line_metrics = calc_line_metrics theme.base_line_height
1749
1863
  end
1750
1864
  if cell_line_metrics
1751
- unless ::Array === (cell_padding = cell_data[:padding]) && cell_padding.size == 4
1865
+ if ::Array === (cell_padding = cell_data[:padding]) && cell_padding.size == 4
1866
+ cell_padding = cell_padding.dup
1867
+ else
1752
1868
  cell_padding = cell_data[:padding] = inflate_padding cell_padding
1753
1869
  end
1754
1870
  cell_padding[0] += cell_line_metrics.padding_top
@@ -1786,6 +1902,11 @@ class Converter < ::Prawn::Document
1786
1902
  table_border_color = theme.table_border_color || theme.table_grid_color || theme.base_border_color
1787
1903
  table_border_style = (theme.table_border_style || :solid).to_sym
1788
1904
  table_border_width = theme.table_border_width
1905
+ if table_header
1906
+ head_border_bottom_color = theme.table_head_border_bottom_color || table_border_color
1907
+ head_border_bottom_style = (theme.table_head_border_bottom_style || table_border_style).to_sym
1908
+ head_border_bottom_width = theme.table_head_border_bottom_width || table_border_width
1909
+ end
1789
1910
  [:top, :bottom, :left, :right].each {|edge| border_width[edge] = table_border_width }
1790
1911
  table_grid_color = theme.table_grid_color || table_border_color
1791
1912
  table_grid_style = (theme.table_grid_style || table_border_style).to_sym
@@ -1835,6 +1956,7 @@ class Converter < ::Prawn::Document
1835
1956
  end
1836
1957
 
1837
1958
  caption_side = (theme.table_caption_side || :top).to_sym
1959
+ caption_max_width = (theme.table_caption_max_width || 'fit-content').to_s
1838
1960
 
1839
1961
  table_settings = {
1840
1962
  header: table_header,
@@ -1867,14 +1989,14 @@ class Converter < ::Prawn::Document
1867
1989
  table table_data, table_settings do
1868
1990
  # NOTE call width to capture resolved table width
1869
1991
  table_width = width
1870
- @pdf.layout_table_caption node, table_width, alignment if node.title? && caption_side == :top
1992
+ caption_max_width = caption_max_width == 'fit-content' ? table_width : nil
1993
+ @pdf.layout_table_caption node, alignment, caption_max_width if node.title? && caption_side == :top
1871
1994
  if grid == 'none' && frame == 'none'
1872
1995
  if table_header
1873
- # FIXME allow header border bottom width and style to be set by theme
1874
1996
  rows(0).tap do |r|
1875
- r.border_bottom_line, r.border_bottom_width = :solid, 1.25
1876
- # QUESTION should we use the table border color for the bottom border color of the header row?
1877
- #r.border_bottom_color, r.border_bottom_line, r.border_bottom_width = table_border_color, :solid, 1.25
1997
+ r.border_bottom_color = head_border_bottom_color
1998
+ r.border_bottom_line = head_border_bottom_style
1999
+ r.border_bottom_width = head_border_bottom_width
1878
2000
  end
1879
2001
  end
1880
2002
  else
@@ -1882,16 +2004,15 @@ class Converter < ::Prawn::Document
1882
2004
  cells.border_width = [border_width[:rows], border_width[:cols], border_width[:rows], border_width[:cols]]
1883
2005
 
1884
2006
  if table_header
1885
- # FIXME allow header border bottom width and style to be set by theme
1886
2007
  rows(0).tap do |r|
1887
- r.border_bottom_line, r.border_bottom_width = :solid, 1.25
1888
- # QUESTION should we use the table border color for the bottom border color of the header row?
1889
- #r.border_bottom_color, r.border_bottom_line, r.border_bottom_width = table_border_color, :solid, 1.25
2008
+ r.border_bottom_color = head_border_bottom_color
2009
+ r.border_bottom_line = head_border_bottom_style
2010
+ r.border_bottom_width = head_border_bottom_width
1890
2011
  end
1891
2012
  rows(1).tap do |r|
1892
- r.border_top_line, r.border_top_width = :solid, 1.25
1893
- # QUESTION should we use the table border color for the top border color of the first row?
1894
- #r.border_top_color, r.border_top_line, r.border_top_width = table_border_color, :solid, 1.25
2013
+ r.border_top_color = head_border_bottom_color
2014
+ r.border_top_line = head_border_bottom_style
2015
+ r.border_top_width = head_border_bottom_width
1895
2016
  end if num_rows > 1
1896
2017
  end
1897
2018
 
@@ -1923,12 +2044,12 @@ class Converter < ::Prawn::Document
1923
2044
  foot_row.font = theme.table_foot_font_family if theme.table_foot_font_family
1924
2045
  foot_row.font_style = theme.table_foot_font_style.to_sym if theme.table_foot_font_style
1925
2046
  # HACK we should do this transformation when creating the cell
1926
- #if (foot_transform = theme.table_foot_text_transform) && foot_transform != 'none'
2047
+ #if (foot_transform = resolve_text_transform :table_foot_text_transform, nil)
1927
2048
  # foot_row.each {|c| c.content = (transform_text c.content, foot_transform) if c.content }
1928
2049
  #end
1929
2050
  end
1930
2051
  end
1931
- layout_table_caption node, table_width, alignment, :bottom if node.title? && caption_side == :bottom
2052
+ layout_table_caption node, alignment, caption_max_width, caption_side if node.title? && caption_side == :bottom
1932
2053
  theme_margin :block, :bottom
1933
2054
  end
1934
2055
 
@@ -1948,18 +2069,23 @@ class Converter < ::Prawn::Document
1948
2069
 
1949
2070
  # NOTE to insert sequential page breaks, you must put {nbsp} between page breaks
1950
2071
  def convert_page_break node
1951
- unless at_page_top?
1952
- if (page_layout = node.attr 'page-layout').nil_or_empty?
1953
- if node.role? && (page_layout = (node.roles.map(&:to_sym) & PageLayouts)[-1])
1954
- advance_page layout: page_layout
1955
- else
1956
- advance_page
1957
- end
1958
- elsif PageLayouts.include?(page_layout = page_layout.to_sym)
2072
+ if (page_layout = node.attr 'page-layout').nil_or_empty?
2073
+ unless node.role? && (page_layout = (node.roles.map(&:to_sym) & PageLayouts)[-1])
2074
+ page_layout = nil
2075
+ end
2076
+ elsif !PageLayouts.include?(page_layout = page_layout.to_sym)
2077
+ page_layout = nil
2078
+ end
2079
+
2080
+ if at_page_top?
2081
+ if page_layout && page_layout != page.layout && page_is_empty?
2082
+ delete_page
1959
2083
  advance_page layout: page_layout
1960
- else
1961
- advance_page
1962
2084
  end
2085
+ elsif page_layout
2086
+ advance_page layout: page_layout
2087
+ else
2088
+ advance_page
1963
2089
  end
1964
2090
  end
1965
2091
 
@@ -2026,7 +2152,7 @@ class Converter < ::Prawn::Document
2026
2152
  elsif (@media ||= node.document.attr 'media', 'screen') != 'screen' || (node.document.attr? 'show-link-uri')
2027
2153
  # QUESTION should we insert breakable chars into URI when building fragment instead?
2028
2154
  # TODO allow style of printed link to be controlled by theme
2029
- %(<a href="#{target = node.target}"#{attrs.join}>#{node.text}</a> [<font size="0.85em">#{breakable_uri target}</font>])
2155
+ %(<a href="#{target = node.target}"#{attrs.join}>#{node.text}</a> [<font size="0.85em">#{breakable_uri target}</font>&#93;)
2030
2156
  else
2031
2157
  %(<a href="#{node.target}"#{attrs.join}>#{node.text}</a>)
2032
2158
  end
@@ -2038,18 +2164,18 @@ class Converter < ::Prawn::Document
2038
2164
  %(<a href="#{node.target}">#{node.text || path}</a>)
2039
2165
  elsif (refid = node.attributes['refid'])
2040
2166
  unless (text = node.text)
2041
- if (refs = node.document.references[:refs])
2167
+ if (refs = node.document.catalog[:refs])
2042
2168
  if ::Asciidoctor::AbstractNode === (ref = refs[refid])
2043
2169
  text = ref.xreftext((@xrefstyle ||= (node.document.attr 'xrefstyle')))
2044
2170
  end
2045
2171
  else
2046
2172
  # Asciidoctor < 1.5.6
2047
- text = node.document.references[:ids][refid]
2173
+ text = node.document.catalog[:ids][refid]
2048
2174
  end
2049
2175
  end
2050
- %(<a anchor="#{derive_anchor_from_id refid}">#{text || "[#{refid}]"}</a>)
2176
+ %(<a anchor="#{derive_anchor_from_id refid}">#{text || "[#{refid}]"}</a>).gsub ']', '&#93;'
2051
2177
  else
2052
- %(<a anchor="#{node.document.attr 'pdf-anchor'}">#{node.text || '[^top]'}</a>)
2178
+ %(<a anchor="#{node.document.attr 'pdf-anchor'}">#{node.text || '[^top&#93;'}</a>)
2053
2179
  end
2054
2180
  when :ref
2055
2181
  # NOTE destination is created inside callback registered by FormattedTextTransform#build_fragment
@@ -2058,9 +2184,14 @@ class Converter < ::Prawn::Document
2058
2184
  when :bibref
2059
2185
  # NOTE destination is created inside callback registered by FormattedTextTransform#build_fragment
2060
2186
  # NOTE technically node.text should be node.reftext, but subs have already been applied to text
2061
- # NOTE check reftext? for compatibility with Asciidoctor <= 1.5.5
2187
+ # NOTE reftext is no longer enclosed in [] starting in Asciidoctor 2.0.0
2062
2188
  # NOTE id is used instead of target starting in Asciidoctor 2.0.0
2063
- %(<a name="#{node.target || node.id}">#{DummyText}</a>#{(reftext = node.reftext) ? reftext : "[#{node.target || node.id}]"})
2189
+ if (reftext = node.reftext)
2190
+ reftext = %([#{reftext}]) unless reftext.start_with? '['
2191
+ else
2192
+ reftext = %([#{node.target || node.id}])
2193
+ end
2194
+ %(<a name="#{node.target || node.id}">#{DummyText}</a>#{reftext})
2064
2195
  else
2065
2196
  logger.warn %(unknown anchor type: #{node.type.inspect})
2066
2197
  end
@@ -2071,8 +2202,7 @@ class Converter < ::Prawn::Document
2071
2202
  end
2072
2203
 
2073
2204
  def convert_inline_button node
2074
- %(<strong>[#{NarrowNoBreakSpace}#{node.text}#{NarrowNoBreakSpace}]</strong>)
2075
- #%(<strong>[#{NoBreakSpace}#{node.text}#{NoBreakSpace}]</strong>)
2205
+ %(<button>#{(@theme.button_content || '%s').sub '%s', node.text}</button>)
2076
2206
  end
2077
2207
 
2078
2208
  def convert_inline_callout node
@@ -2148,11 +2278,15 @@ class Converter < ::Prawn::Document
2148
2278
  logger.warn %(GIF image format not supported. Install the prawn-gmagick gem or convert #{target} to PNG.) unless scratch?
2149
2279
  img = %([#{node.attr 'alt'}])
2150
2280
  # NOTE an image with a data URI is handled using a temporary file
2151
- elsif (image_path = resolve_image_path node, target, true, image_format) && (::File.readable? image_path)
2152
- width_attr = (width = preresolve_explicit_width node.attributes) ? %( width="#{width}") : nil
2153
- img = %(<img src="#{image_path}" format="#{image_format}" alt="[#{encode_quotes node.attr 'alt'}]"#{width_attr} tmp="#{TemporaryPath === image_path}">)
2281
+ elsif (image_path = resolve_image_path node, target, true, image_format)
2282
+ if ::File.readable? image_path
2283
+ width_attr = (width = preresolve_explicit_width node.attributes) ? %( width="#{width}") : nil
2284
+ img = %(<img src="#{image_path}" format="#{image_format}" alt="[#{encode_quotes node.attr 'alt'}]"#{width_attr} tmp="#{TemporaryPath === image_path}">)
2285
+ else
2286
+ logger.warn %(image to embed not found or not readable: #{image_path}) unless scratch?
2287
+ img = %([#{node.attr 'alt'}])
2288
+ end
2154
2289
  else
2155
- logger.warn %(image to embed not found or not readable: #{image_path || target}) unless scratch?
2156
2290
  img = %([#{node.attr 'alt'}])
2157
2291
  end
2158
2292
  (node.attr? 'link', nil, false) ? %(<a href="#{node.attr 'link'}">#{img}</a>) : img
@@ -2166,7 +2300,7 @@ class Converter < ::Prawn::Document
2166
2300
  node.type == :visible ? node.text : ''
2167
2301
  else
2168
2302
  dest = {
2169
- anchor: (anchor_name = %(__indexterm-#{node.object_id}))
2303
+ anchor: (anchor_name = @index.next_anchor_name)
2170
2304
  # NOTE page number is added in InlineDestinationMarker
2171
2305
  }
2172
2306
  anchor = %(<a name="#{anchor_name}" type="indexterm">#{DummyText}</a>)
@@ -2238,19 +2372,16 @@ class Converter < ::Prawn::Document
2238
2372
  def layout_title_page doc
2239
2373
  return unless doc.header? && !doc.notitle
2240
2374
 
2241
- prev_bg_image = @page_bg_image
2375
+ prev_bg_image = @page_bg_image[side = page_side]
2242
2376
  prev_bg_color = @page_bg_color
2243
-
2244
- if (bg_image = resolve_background_image doc, @theme, 'title-page-background-image')
2245
- @page_bg_image = (bg_image == 'none' ? nil : bg_image)
2246
- end
2377
+ @page_bg_image[side] = (bg_image = resolve_background_image doc, @theme, 'title-page-background-image') && bg_image[0] ? bg_image : nil
2247
2378
  if (bg_color = resolve_theme_color :title_page_background_color)
2248
2379
  @page_bg_color = bg_color
2249
2380
  end
2250
2381
  # NOTE a new page will already be started if the cover image is a PDF
2251
2382
  start_new_page unless page_is_empty?
2252
2383
  start_new_page if @ppbook && verso_page?
2253
- @page_bg_image = prev_bg_image if bg_image
2384
+ @page_bg_image[side] = prev_bg_image if prev_bg_image
2254
2385
  @page_bg_color = prev_bg_color if bg_color
2255
2386
 
2256
2387
  # IMPORTANT this is the first page created, so we need to set the base font
@@ -2270,9 +2401,7 @@ class Converter < ::Prawn::Document
2270
2401
  relative_to_imagesdir = false
2271
2402
  end
2272
2403
  # HACK quick fix to resolve image path relative to theme
2273
- unless doc.attr? 'title-logo-image'
2274
- logo_image_path = ThemeLoader.resolve_theme_asset logo_image_path, (doc.attr 'pdf-stylesdir')
2275
- end
2404
+ logo_image_path = ThemeLoader.resolve_theme_asset logo_image_path, @themesdir unless doc.attr? 'title-logo-image'
2276
2405
  logo_image_attrs['target'] = logo_image_path
2277
2406
  logo_image_attrs['align'] ||= (@theme.title_page_logo_align || title_align.to_s)
2278
2407
  # QUESTION should we allow theme to turn logo image off?
@@ -2287,7 +2416,9 @@ class Converter < ::Prawn::Document
2287
2416
  # FIXME add API to Asciidoctor for creating blocks like this (extract from extensions module?)
2288
2417
  image_block = ::Asciidoctor::Block.new doc, :image, content_model: :empty, attributes: logo_image_attrs
2289
2418
  # NOTE pinned option keeps image on same page
2290
- convert_image image_block, relative_to_imagesdir: relative_to_imagesdir, pinned: true
2419
+ indent (@theme.title_page_logo_margin_left || 0), (@theme.title_page_logo_margin_right || 0) do
2420
+ convert_image image_block, relative_to_imagesdir: relative_to_imagesdir, pinned: true
2421
+ end
2291
2422
  @y = initial_y
2292
2423
  end
2293
2424
 
@@ -2304,34 +2435,40 @@ class Converter < ::Prawn::Document
2304
2435
  @y = title_top
2305
2436
  end
2306
2437
  move_down(@theme.title_page_title_margin_top || 0)
2307
- theme_font :title_page_title do
2308
- layout_heading doctitle.main,
2309
- align: title_align,
2310
- margin: 0,
2311
- line_height: @theme.title_page_title_line_height
2438
+ indent (@theme.title_page_title_margin_left || 0), (@theme.title_page_title_margin_right || 0) do
2439
+ theme_font :title_page_title do
2440
+ layout_heading doctitle.main,
2441
+ align: title_align,
2442
+ margin: 0,
2443
+ line_height: @theme.title_page_title_line_height
2444
+ end
2312
2445
  end
2313
2446
  move_down(@theme.title_page_title_margin_bottom || 0)
2314
2447
  if doctitle.subtitle
2315
2448
  move_down(@theme.title_page_subtitle_margin_top || 0)
2316
- theme_font :title_page_subtitle do
2317
- layout_heading doctitle.subtitle,
2318
- align: title_align,
2319
- margin: 0,
2320
- line_height: @theme.title_page_subtitle_line_height
2449
+ indent (@theme.title_page_subtitle_margin_left || 0), (@theme.title_page_subtitle_margin_right || 0) do
2450
+ theme_font :title_page_subtitle do
2451
+ layout_heading doctitle.subtitle,
2452
+ align: title_align,
2453
+ margin: 0,
2454
+ line_height: @theme.title_page_subtitle_line_height
2455
+ end
2321
2456
  end
2322
2457
  move_down(@theme.title_page_subtitle_margin_bottom || 0)
2323
2458
  end
2324
2459
  if doc.attr? 'authors'
2325
2460
  move_down(@theme.title_page_authors_margin_top || 0)
2326
- # TODO provide an API in core to get authors as an array
2327
- authors = (1..(doc.attr 'authorcount', 1).to_i).map {|idx|
2328
- doc.attr(idx == 1 ? 'author' : %(author_#{idx}))
2329
- } * (@theme.title_page_authors_delimiter || ', ')
2330
- theme_font :title_page_authors do
2331
- layout_prose authors,
2332
- align: title_align,
2333
- margin: 0,
2334
- normalize: false
2461
+ indent (@theme.title_page_authors_margin_left || 0), (@theme.title_page_authors_margin_right || 0) do
2462
+ # TODO provide an API in core to get authors as an array
2463
+ authors = (1..(doc.attr 'authorcount', 1).to_i).map {|idx|
2464
+ doc.attr(idx == 1 ? 'author' : %(author_#{idx}))
2465
+ } * (@theme.title_page_authors_delimiter || ', ')
2466
+ theme_font :title_page_authors do
2467
+ layout_prose authors,
2468
+ align: title_align,
2469
+ margin: 0,
2470
+ normalize: false
2471
+ end
2335
2472
  end
2336
2473
  move_down(@theme.title_page_authors_margin_bottom || 0)
2337
2474
  end
@@ -2339,41 +2476,46 @@ class Converter < ::Prawn::Document
2339
2476
  unless revision_info.empty?
2340
2477
  move_down(@theme.title_page_revision_margin_top || 0)
2341
2478
  revision_text = revision_info * (@theme.title_page_revision_delimiter || ', ')
2342
- theme_font :title_page_revision do
2343
- layout_prose revision_text,
2344
- align: title_align,
2345
- margin: 0,
2346
- normalize: false
2479
+ indent (@theme.title_page_revision_margin_left || 0), (@theme.title_page_revision_margin_right || 0) do
2480
+ theme_font :title_page_revision do
2481
+ layout_prose revision_text,
2482
+ align: title_align,
2483
+ margin: 0,
2484
+ normalize: false
2485
+ end
2347
2486
  end
2348
2487
  move_down(@theme.title_page_revision_margin_bottom || 0)
2349
2488
  end
2350
2489
  end
2351
2490
  end
2352
2491
 
2353
- def layout_cover_page face, doc
2492
+ def layout_cover_page doc, face
2354
2493
  # TODO turn processing of attribute with inline image a utility function in Asciidoctor
2355
- if (cover_image = (doc.attr %(#{face}-cover-image)))
2356
- if (cover_image.include? ':') && cover_image =~ ImageAttributeValueRx
2357
- # TODO support explicit image format
2358
- cover_image = resolve_image_path doc, $1
2494
+ if (image_path = (doc.attr %(#{face}-cover-image)))
2495
+ if (image_path.include? ':') && image_path =~ ImageAttributeValueRx
2496
+ image_attrs = (AttributeList.new $2).parse ['alt', 'width']
2497
+ image_path = resolve_image_path doc, $1, true, (image_format = image_attrs['format'])
2359
2498
  else
2360
- cover_image = resolve_image_path doc, cover_image, false
2499
+ image_path = resolve_image_path doc, image_path, false
2361
2500
  end
2362
2501
 
2363
- if ::File.readable? cover_image
2364
- go_to_page page_count if face == :back
2365
- if cover_image.downcase.end_with? '.pdf'
2366
- # NOTE import_page automatically advances to next page afterwards (can we change this behavior?)
2367
- import_page cover_image, advance: face != :back
2368
- else
2369
- image_page cover_image, canvas: true
2370
- end
2502
+ return unless image_path
2503
+
2504
+ unless ::File.readable? image_path
2505
+ logger.warn %(#{face} cover image not found or readable: #{image_path})
2506
+ return
2507
+ end
2508
+
2509
+ go_to_page page_count if face == :back
2510
+ if image_path.downcase.end_with? '.pdf'
2511
+ import_page image_path, advance: face != :back
2371
2512
  else
2372
- logger.warn %(#{face} cover image not found or readable: #{cover_image})
2513
+ image_opts = resolve_image_options image_path, image_attrs, background: true, format: image_format
2514
+ image_page image_path, (image_opts.merge canvas: true)
2373
2515
  end
2374
2516
  end
2375
2517
  ensure
2376
- unlink_tmp_file cover_image if cover_image
2518
+ unlink_tmp_file image_path if image_path
2377
2519
  end
2378
2520
 
2379
2521
  def start_new_chapter chapter
@@ -2394,7 +2536,7 @@ class Converter < ::Prawn::Document
2394
2536
  def layout_heading string, opts = {}
2395
2537
  top_margin = (margin = (opts.delete :margin)) || (opts.delete :margin_top) || @theme[%(heading_h#{opts[:level]}_margin_top)] || @theme.heading_margin_top
2396
2538
  bot_margin = margin || (opts.delete :margin_bottom) || @theme[%(heading_h#{opts[:level]}_margin_bottom)] || @theme.heading_margin_bottom
2397
- if (transform = (opts.delete :text_transform) || @text_transform) && transform != 'none'
2539
+ if (transform = resolve_text_transform opts)
2398
2540
  string = transform_text string, transform
2399
2541
  end
2400
2542
  margin_top top_margin
@@ -2410,7 +2552,7 @@ class Converter < ::Prawn::Document
2410
2552
  def layout_prose string, opts = {}
2411
2553
  top_margin = (margin = (opts.delete :margin)) || (opts.delete :margin_top) || @theme.prose_margin_top
2412
2554
  bot_margin = margin || (opts.delete :margin_bottom) || @theme.prose_margin_bottom
2413
- if (transform = (opts.delete :text_transform) || @text_transform) && transform != 'none'
2555
+ if (transform = resolve_text_transform opts)
2414
2556
  string = transform_text string, transform
2415
2557
  end
2416
2558
  # NOTE used by extensions; ensures linked text gets formatted using the link styles
@@ -2483,14 +2625,19 @@ class Converter < ::Prawn::Document
2483
2625
  end
2484
2626
 
2485
2627
  # Render the caption for a table and return the height of the rendered content
2486
- def layout_table_caption node, width, alignment = :left, side = :top
2487
- # QUESTION should we confine width of title to width of table?
2488
- if alignment == :left || (excess = bounds.width - width) == 0
2489
- layout_caption node, side: side
2490
- else
2491
- indent excess * (alignment == :center ? 0.5 : 1) do
2492
- layout_caption node, side: side
2628
+ def layout_table_caption node, table_alignment = :left, max_width = nil, side = :top
2629
+ if max_width && (remainder = bounds.width - max_width) > 0
2630
+ case table_alignment
2631
+ when :right
2632
+ indent(remainder) { layout_caption node, side: side }
2633
+ when :center
2634
+ side_margin = remainder * 0.5
2635
+ indent(side_margin, side_margin) { layout_caption node, side: side }
2636
+ else # :left
2637
+ indent(0, remainder) { layout_caption node, side: side }
2493
2638
  end
2639
+ else
2640
+ layout_caption node, side: side
2494
2641
  end
2495
2642
  end
2496
2643
 
@@ -2505,8 +2652,8 @@ class Converter < ::Prawn::Document
2505
2652
  layout_heading((doc.attr 'toc-title'), align: toc_title_align)
2506
2653
  end
2507
2654
  end
2508
- # QUESTION should we skip this whole method if num_levels == 0?
2509
- if num_levels > 0
2655
+ # QUESTION should we skip this whole method if num_levels < 0?
2656
+ unless num_levels < 0
2510
2657
  dot_leader = theme_font :toc do
2511
2658
  # TODO we could simplify by using nested theme_font :toc_dot_leader
2512
2659
  if (dot_leader_font_style = (@theme.toc_dot_leader_font_style || :normal).to_sym) != font_style
@@ -2541,8 +2688,7 @@ class Converter < ::Prawn::Document
2541
2688
  end
2542
2689
  sections.each do |sect|
2543
2690
  theme_font :toc, level: (sect.level + 1) do
2544
- sect_title = (transform = @text_transform) && transform != 'none' ?
2545
- (transform_text sect.numbered_title, transform) : sect.numbered_title
2691
+ sect_title = (transform = @text_transform) ? (transform_text sect.numbered_title, transform) : sect.numbered_title
2546
2692
  # NOTE only write section title (excluding dots and page number) if this is a dry run
2547
2693
  if scratch?
2548
2694
  # FIXME use layout_prose
@@ -2618,7 +2764,8 @@ class Converter < ::Prawn::Document
2618
2764
 
2619
2765
  # TODO delegate to layout_page_header and layout_page_footer per page
2620
2766
  def layout_running_content periphery, doc, opts = {}
2621
- skip = opts[:skip] || 1
2767
+ skip, skip_pagenums, body_start_page_number = opts[:skip] || [1, 1]
2768
+ body_start_page_number = opts[:body_start_page_number] || 1
2622
2769
  # NOTE find and advance to first non-imported content page to use as model page
2623
2770
  return unless (content_start_page = state.pages[skip..-1].index {|p| !p.imported_page? })
2624
2771
  content_start_page += (skip + 1)
@@ -2629,8 +2776,8 @@ class Converter < ::Prawn::Document
2629
2776
  # FIXME probably need to treat doctypes differently
2630
2777
  is_book = doc.doctype == 'book'
2631
2778
  header = doc.header? ? doc.header : nil
2632
- # TODO make this section threshold configurable (perhaps in theme?)
2633
- sections = doc.find_by(context: :section) {|sect| sect.level < 3 && sect != header } || []
2779
+ sectlevels = (@theme[%(#{periphery}_sectlevels)] || 2).to_i
2780
+ sections = doc.find_by(context: :section) {|sect| sect.level <= sectlevels && sect != header } || []
2634
2781
 
2635
2782
  # FIXME we need a proper model for all this page counting
2636
2783
  # FIXME we make a big assumption that part & chapter start on new pages
@@ -2640,7 +2787,7 @@ class Converter < ::Prawn::Document
2640
2787
  section_start_pages = {}
2641
2788
  trailing_section_start_pages = {}
2642
2789
  sections.each do |sect|
2643
- page_num = (sect.attr 'pdf-page-start').to_i - skip
2790
+ page_num = (sect.attr 'pdf-page-start').to_i - skip_pagenums
2644
2791
  if is_book && ((sect_is_part = sect.part?) || sect.chapter?)
2645
2792
  if sect_is_part
2646
2793
  part_start_pages[page_num] ||= (sect.numbered_title formal: true)
@@ -2663,8 +2810,8 @@ class Converter < ::Prawn::Document
2663
2810
  sections_by_page = {}
2664
2811
  # QUESTION should the default part be the doctitle?
2665
2812
  last_part = nil
2666
- # QUESTION should we enforce that the preamble is preface?
2667
- last_chap = is_book ? (doc.attr 'preface-title', 'Preface') : nil
2813
+ # QUESTION should we enforce that the preamble is a preface?
2814
+ last_chap = is_book ? :pre : nil
2668
2815
  last_sect = nil
2669
2816
  sect_search_threshold = 1
2670
2817
  (1..num_pages).each do |num|
@@ -2691,7 +2838,17 @@ class Converter < ::Prawn::Document
2691
2838
  end
2692
2839
  end
2693
2840
  parts_by_page[num] = last_part
2694
- chapters_by_page[num] = last_chap
2841
+ if last_chap == :pre
2842
+ if num == 1
2843
+ chapters_by_page[num] = doc.doctitle
2844
+ elsif num >= body_start_page_number
2845
+ chapters_by_page[num] = is_book ? (doc.attr 'preface-title', 'Preface') : nil
2846
+ else
2847
+ chapters_by_page[num] = doc.attr 'toc-title'
2848
+ end
2849
+ else
2850
+ chapters_by_page[num] = last_chap
2851
+ end
2695
2852
  sections_by_page[num] = last_sect
2696
2853
  end
2697
2854
 
@@ -2701,164 +2858,6 @@ class Converter < ::Prawn::Document
2701
2858
  doc.set_attr 'document-title', doctitle.main
2702
2859
  doc.set_attr 'document-subtitle', doctitle.subtitle
2703
2860
  doc.set_attr 'page-count', num_pages
2704
- allow_uri_read = doc.attr? 'allow-uri-read'
2705
- svg_fallback_font = default_svg_font
2706
-
2707
- if periphery == :header
2708
- trim_line_metrics = calc_line_metrics(@theme.header_line_height || @theme.base_line_height)
2709
- trim_top = page_height
2710
- # NOTE height is required atm
2711
- trim_height = @theme.header_height || page_margin_top
2712
- trim_padding = @theme.header_padding || [0, 0, 0, 0]
2713
- trim_bg_color = resolve_theme_color :header_background_color
2714
- trim_border_width = @theme.header_border_width || @theme.base_border_width
2715
- trim_border_style = (@theme.header_border_style || :solid).to_sym
2716
- trim_border_color = resolve_theme_color :header_border_color
2717
- trim_valign = (@theme.header_vertical_align || :middle).to_sym
2718
- trim_img_valign = @theme.header_image_vertical_align
2719
- else
2720
- trim_line_metrics = calc_line_metrics(@theme.footer_line_height || @theme.base_line_height)
2721
- # NOTE height is required atm
2722
- trim_top = trim_height = @theme.footer_height || page_margin_bottom
2723
- trim_padding = @theme.footer_padding || [0, 0, 0, 0]
2724
- trim_bg_color = resolve_theme_color :footer_background_color
2725
- trim_border_width = @theme.footer_border_width || @theme.base_border_width
2726
- trim_border_style = (@theme.footer_border_style || :solid).to_sym
2727
- trim_border_color = resolve_theme_color :footer_border_color
2728
- trim_valign = (@theme.footer_vertical_align || :middle).to_sym
2729
- trim_img_valign = @theme.footer_image_vertical_align
2730
- end
2731
-
2732
- trim_stamp_name = {
2733
- recto: %(#{periphery}_recto),
2734
- verso: %(#{periphery}_verso)
2735
- }
2736
- trim_left = {
2737
- recto: @page_margin_by_side[:recto][3],
2738
- verso: @page_margin_by_side[:verso][3]
2739
- }
2740
- trim_width = {
2741
- recto: page_width - trim_left[:recto] - @page_margin_by_side[:recto][1],
2742
- verso: page_width - trim_left[:verso] - @page_margin_by_side[:verso][1]
2743
- }
2744
- trim_content_left = {
2745
- recto: trim_left[:recto] + trim_padding[3],
2746
- verso: trim_left[:verso] + trim_padding[3]
2747
- }
2748
- trim_content_width = {
2749
- recto: trim_width[:recto] - trim_padding[3] - trim_padding[1],
2750
- verso: trim_width[:verso] - trim_padding[3] - trim_padding[1]
2751
- }
2752
- trim_content_height = trim_height - trim_padding[0] - trim_padding[2] - trim_line_metrics.padding_top - trim_line_metrics.padding_bottom
2753
- trim_border_color = nil if trim_border_width == 0
2754
- trim_valign = :center if trim_valign == :middle
2755
- case trim_img_valign
2756
- when nil
2757
- trim_img_valign = trim_valign
2758
- when 'middle'
2759
- trim_img_valign = :center
2760
- when 'top', 'center', 'bottom'
2761
- trim_img_valign = trim_img_valign.to_sym
2762
- end
2763
-
2764
- colspec_dict = PageSides.inject({}) do |acc, side|
2765
- side_trim_content_width = trim_content_width[side]
2766
- if (custom_colspecs = @theme[%(#{periphery}_#{side}_columns)] || @theme[%(#{periphery}_columns)])
2767
- case (colspecs = (custom_colspecs.to_s.tr ',', ' ').split[0..2]).size
2768
- when 3
2769
- colspecs = { left: colspecs[0], center: colspecs[1], right: colspecs[2] }
2770
- when 2
2771
- colspecs = { left: colspecs[0], center: '0', right: colspecs[1] }
2772
- when 0, 1
2773
- colspecs = { left: '0', center: colspecs[0] || '100', right: '0' }
2774
- end
2775
- tot_width = 0
2776
- side_colspecs = colspecs.map {|col, spec|
2777
- if (alignment_char = spec.chr).to_i.to_s != alignment_char
2778
- alignment = AlignmentTable[alignment_char] || :left
2779
- rel_width = spec[1..-1].to_f
2780
- else
2781
- alignment = :left
2782
- rel_width = spec.to_f
2783
- end
2784
- tot_width += rel_width
2785
- [col, { align: alignment, width: rel_width, x: 0 }]
2786
- }.to_h
2787
- # QUESTION should we allow the columns to overlap (capping width at 100%)?
2788
- side_colspecs.each {|_, colspec| colspec[:width] = (colspec[:width] / tot_width) * side_trim_content_width }
2789
- side_colspecs[:right][:x] = (side_colspecs[:center][:x] = side_colspecs[:left][:width]) + side_colspecs[:center][:width]
2790
- acc[side] = side_colspecs
2791
- else
2792
- acc[side] = {
2793
- left: { align: :left, width: side_trim_content_width, x: 0 },
2794
- center: { align: :center, width: side_trim_content_width, x: 0 },
2795
- right: { align: :right, width: side_trim_content_width, x: 0 }
2796
- }
2797
- end
2798
- acc
2799
- end
2800
-
2801
- # TODO move this to a method so it can be reused; cache results
2802
- content_dict = PageSides.inject({}) do |acc, side|
2803
- side_content = {}
2804
- ColumnPositions.each do |position|
2805
- unless (val = @theme[%(#{periphery}_#{side}_#{position}_content)]).nil_or_empty?
2806
- # TODO support image URL (using resolve_image_path)
2807
- if (val.include? ':') && val =~ ImageAttributeValueRx &&
2808
- ::File.readable?(path = (ThemeLoader.resolve_theme_asset $1, (doc.attr 'pdf-stylesdir')))
2809
- attrs = (AttributeList.new $2).parse
2810
- col_width = colspec_dict[side][position][:width]
2811
- if (fit = attrs['fit']) == 'contain'
2812
- width = col_width
2813
- else
2814
- unless (width = resolve_explicit_width attrs, col_width)
2815
- # QUESTION should we lookup and scale intrinsic width if explicit width is not given?
2816
- # NOTE failure message will be reported later when image is rendered
2817
- width = (to_pt intrinsic_image_dimensions(path)[:width], :px) rescue 0
2818
- end
2819
- width = col_width if fit == 'scale-down' && width > col_width
2820
- end
2821
- side_content[position] = { path: path, width: width, fit: !!fit }
2822
- else
2823
- side_content[position] = val
2824
- end
2825
- end
2826
- end
2827
- # NOTE set fallbacks if not explicitly disabled
2828
- if side_content.empty? && periphery == :footer && @theme[%(footer_#{side}_content)] != 'none'
2829
- side_content = { side == :recto ? :right : :left => '{page-number}' }
2830
- end
2831
-
2832
- acc[side] = side_content
2833
- acc
2834
- end
2835
-
2836
- stamps = {}
2837
- if trim_bg_color || trim_border_color
2838
- PageSides.each do |side|
2839
- create_stamp trim_stamp_name[side] do
2840
- canvas do
2841
- if trim_bg_color
2842
- bounding_box [0, trim_top], width: bounds.width, height: trim_height do
2843
- fill_bounds trim_bg_color
2844
- if trim_border_color
2845
- # TODO stroke_horizontal_rule should support :at
2846
- move_down bounds.height if periphery == :header
2847
- stroke_horizontal_rule trim_border_color, line_width: trim_border_width, line_style: trim_border_style
2848
- end
2849
- end
2850
- else
2851
- bounding_box [trim_left[side], trim_top], width: trim_width[side], height: trim_height do
2852
- # TODO stroke_horizontal_rule should support :at
2853
- move_down bounds.height if periphery == :header
2854
- stroke_horizontal_rule trim_border_color, line_width: trim_border_width, line_style: trim_border_style
2855
- end
2856
- end
2857
- end
2858
- end
2859
- end
2860
- stamps[periphery] = true
2861
- end
2862
2861
 
2863
2862
  pagenums_enabled = doc.attr? 'pagenums'
2864
2863
  attribute_missing_doc = doc.attr 'attribute-missing'
@@ -2872,11 +2871,15 @@ class Converter < ::Prawn::Document
2872
2871
  else
2873
2872
  folio_basis, invert_folio = :virtual, false
2874
2873
  end
2874
+ periphery_layout_cache = {}
2875
2875
  repeat((content_start_page..page_count), dynamic: true) do
2876
2876
  # NOTE don't write on pages which are imported / inserts (otherwise we can get a corrupt PDF)
2877
2877
  next if page.imported_page?
2878
- pgnum_label = page_number - skip
2878
+ pgnum_label = page_number - skip_pagenums
2879
+ pgnum_label = (RomanNumeral.new page_number, :lower) if pgnum_label < 1
2879
2880
  side = page_side((folio_basis == :physical ? page_number : pgnum_label), invert_folio)
2881
+ # QUESTION should allocation be per side?
2882
+ trim_styles, colspec_dict, content_dict, stamp_names = allocate_running_content_layout page, periphery, periphery_layout_cache
2880
2883
  # FIXME we need to have a content setting for chapter pages
2881
2884
  content_by_position, colspec_by_position = content_dict[side], colspec_dict[side]
2882
2885
  # TODO populate chapter-number
@@ -2888,77 +2891,73 @@ class Converter < ::Prawn::Document
2888
2891
  doc.set_attr 'section-title', (sections_by_page[pgnum_label] || '')
2889
2892
  doc.set_attr 'section-or-chapter-title', (sections_by_page[pgnum_label] || chapters_by_page[pgnum_label] || '')
2890
2893
 
2891
- stamp trim_stamp_name[side] if stamps[periphery]
2894
+ stamp stamp_names[side] if stamp_names
2892
2895
 
2893
2896
  theme_font periphery do
2894
2897
  canvas do
2895
- bounding_box [trim_content_left[side], trim_top], width: trim_content_width[side], height: trim_height do
2898
+ bounding_box [trim_styles[:content_left][side], trim_styles[:top]], width: trim_styles[:content_width][side], height: trim_styles[:height] do
2899
+ if (trim_column_rule_width = trim_styles[:column_rule_width]) > 0
2900
+ trim_column_rule_spacing = trim_styles[:column_rule_spacing]
2901
+ else
2902
+ trim_column_rule_width = nil
2903
+ end
2904
+ prev_position = nil
2896
2905
  ColumnPositions.each do |position|
2897
2906
  next unless (content = content_by_position[position])
2898
2907
  next unless (colspec = colspec_by_position[position])[:width] > 0
2908
+ left, colwidth = colspec[:x], colspec[:width]
2909
+ if trim_column_rule_width && colwidth < bounds.width
2910
+ if (trim_column_rule = prev_position)
2911
+ left += (trim_column_rule_spacing * 0.5)
2912
+ colwidth -= trim_column_rule_spacing
2913
+ else
2914
+ colwidth -= (trim_column_rule_spacing * 0.5)
2915
+ end
2916
+ end
2899
2917
  # FIXME we need to have a content setting for chapter pages
2900
2918
  case content
2901
- when ::Hash
2902
- # NOTE image vposition respects padding; use negative image_vertical_align value to revert
2903
- trim_v_padding = trim_padding[0] + trim_padding[2]
2919
+ when ::Array
2904
2920
  # NOTE float ensures cursor position is restored and returns us to current page if we overrun
2905
2921
  float do
2906
- # NOTE bounding_box is redundant if trim_v_padding is 0
2907
- bounding_box [colspec[:x], cursor - trim_padding[0]], width: colspec[:width], height: (bounds.height - trim_v_padding) do
2908
- begin
2909
- if (img_path = content[:path]).downcase.end_with? '.svg'
2910
- svg_data = ::File.read img_path
2911
- svg_obj = ::Prawn::SVG::Interface.new svg_data, self,
2912
- position: colspec[:align],
2913
- vposition: trim_img_valign,
2914
- width: content[:width],
2915
- # TODO enforce jail in safe mode
2916
- enable_file_requests_with_root: (::File.dirname img_path),
2917
- enable_web_requests: allow_uri_read,
2918
- fallback_font_name: svg_fallback_font
2919
- if content[:fit] && svg_obj.document.sizing.output_height > (available_h = bounds.height)
2920
- svg_obj.resize height: available_h
2921
- end
2922
- svg_obj.draw
2923
- else
2924
- img_opts = { position: colspec[:align], vposition: trim_img_valign }
2925
- if content[:fit]
2926
- img_opts[:fit] = [content[:width], bounds.height]
2927
- else
2928
- img_opts[:width] = content[:width]
2929
- end
2930
- image img_path, img_opts
2931
- end
2932
- rescue
2933
- logger.warn %(could not embed image in running content: #{img_path}; #{$!.message})
2934
- end
2922
+ # NOTE bounding_box is redundant if both vertical padding and border width are 0
2923
+ bounding_box [left, bounds.top - trim_styles[:padding][0] - trim_styles[:content_offset]], width: colwidth, height: trim_styles[:content_height] do
2924
+ # NOTE image vposition respects padding; use negative image_vertical_align value to revert
2925
+ image_opts = content[1].merge position: colspec[:align], vposition: trim_styles[:img_valign]
2926
+ image content[0], image_opts rescue logger.warn %(could not embed image in running content: #{content[0]}; #{$!.message})
2935
2927
  end
2936
2928
  end
2937
2929
  when ::String
2938
- # NOTE minor optimization
2939
- if content == '{page-number}'
2940
- content = pagenums_enabled ? pgnum_label.to_s : nil
2941
- else
2942
- # FIXME get apply_subs to handle drop-line w/o a warning
2943
- doc.set_attr 'attribute-missing', 'skip' unless attribute_missing_doc == 'skip'
2944
- if (content = doc.apply_subs content).include? '{'
2945
- # NOTE must use &#123; in place of {, not \{, to escape attribute reference
2946
- content = content.split(LF).delete_if {|line| SimpleAttributeRefRx =~ line } * LF
2947
- end
2948
- doc.set_attr 'attribute-missing', attribute_missing_doc unless attribute_missing_doc == 'skip'
2949
- end
2950
2930
  theme_font %(#{periphery}_#{side}_#{position}) do
2931
+ # NOTE minor optimization
2932
+ if content == '{page-number}'
2933
+ content = pagenums_enabled ? pgnum_label.to_s : nil
2934
+ else
2935
+ # FIXME get apply_subs to handle drop-line w/o a warning
2936
+ doc.set_attr 'attribute-missing', 'skip' unless attribute_missing_doc == 'skip'
2937
+ if (content = doc.apply_subs content).include? '{'
2938
+ # NOTE must use &#123; in place of {, not \{, to escape attribute reference
2939
+ content = content.split(LF).delete_if {|line| SimpleAttributeRefRx.match? line } * LF
2940
+ end
2941
+ doc.set_attr 'attribute-missing', attribute_missing_doc unless attribute_missing_doc == 'skip'
2942
+ if (transform = @text_transform) && transform != 'none'
2943
+ content = transform_text content, @text_transform
2944
+ end
2945
+ end
2951
2946
  formatted_text_box parse_text(content, color: @font_color, inline_format: [normalize: true]),
2952
- at: [colspec[:x], trim_content_height + trim_padding[2] + trim_line_metrics.padding_bottom],
2953
- width: colspec[:width],
2954
- height: trim_content_height,
2947
+ at: [left, bounds.top - trim_styles[:padding][0] - trim_styles[:content_offset] + (trim_styles[:valign] == :center ? font.descender * 0.5 : 0)],
2948
+ width: colwidth,
2949
+ height: trim_styles[:prose_content_height],
2955
2950
  align: colspec[:align],
2956
- valign: trim_valign,
2957
- leading: trim_line_metrics.leading,
2951
+ valign: trim_styles[:valign],
2952
+ leading: trim_styles[:line_metrics].leading,
2958
2953
  final_gap: false,
2959
2954
  overflow: :truncate
2960
2955
  end
2961
2956
  end
2957
+ bounding_box [colspec[:x], bounds.top - trim_styles[:padding][0] - trim_styles[:content_offset]], width: colspec[:width], height: trim_styles[:content_height] do
2958
+ stroke_vertical_rule trim_styles[:column_rule_color], at: bounds.left, line_style: trim_styles[:column_rule_style], line_width: trim_column_rule_width
2959
+ end if trim_column_rule
2960
+ prev_position = position
2962
2961
  end
2963
2962
  end
2964
2963
  end
@@ -2969,6 +2968,150 @@ class Converter < ::Prawn::Document
2969
2968
  nil
2970
2969
  end
2971
2970
 
2971
+ def allocate_running_content_layout page, periphery, cache
2972
+ layout = page.layout
2973
+ cache[layout] ||= begin
2974
+ trim_styles = {
2975
+ line_metrics: (trim_line_metrics = calc_line_metrics @theme[%(#{periphery}_line_height)] || @theme.base_line_height),
2976
+ # NOTE we've already verified this property is set
2977
+ height: (trim_height = @theme[%(#{periphery}_height)]),
2978
+ top: periphery == :header ? page_height : trim_height,
2979
+ padding: (trim_padding = inflate_padding @theme[%(#{periphery}_padding)] || 0),
2980
+ bg_color: (resolve_theme_color %(#{periphery}_background_color).to_sym),
2981
+ border_color: (trim_border_color = resolve_theme_color %(#{periphery}_border_color).to_sym),
2982
+ border_style: (@theme[%(#{periphery}_border_style)] || :solid).to_sym,
2983
+ border_width: (trim_border_width = trim_border_color ? @theme[%(#{periphery}_border_width)] || @theme.base_border_width || 0 : 0),
2984
+ column_rule_color: (trim_column_rule_color = resolve_theme_color %(#{periphery}_column_rule_color).to_sym),
2985
+ column_rule_style: (@theme[%(#{periphery}_column_rule_style)] || :solid).to_sym,
2986
+ column_rule_width: (trim_column_rule_color ? @theme[%(#{periphery}_column_rule_width)] || 0 : 0),
2987
+ column_rule_spacing: (trim_column_rule_spacing = @theme[%(#{periphery}_column_rule_spacing)] || 0),
2988
+ valign: (val = (@theme[%(#{periphery}_vertical_align)] || :middle).to_sym) == :middle ? :center : val,
2989
+ img_valign: @theme[%(#{periphery}_image_vertical_align)],
2990
+ left: {
2991
+ recto: (trim_left_recto = @page_margin_by_side[:recto][3]),
2992
+ verso: (trim_left_verso = @page_margin_by_side[:verso][3]),
2993
+ },
2994
+ width: {
2995
+ recto: (trim_width_recto = page_width - trim_left_recto - @page_margin_by_side[:recto][1]),
2996
+ verso: (trim_width_verso = page_width - trim_left_verso - @page_margin_by_side[:verso][1]),
2997
+ },
2998
+ content_left: {
2999
+ recto: trim_left_recto + trim_padding[3],
3000
+ verso: trim_left_verso + trim_padding[3],
3001
+ },
3002
+ content_width: (trim_content_width = {
3003
+ recto: trim_width_recto - trim_padding[1] - trim_padding[3],
3004
+ verso: trim_width_verso - trim_padding[1] - trim_padding[3],
3005
+ }),
3006
+ content_height: (content_height = trim_height - trim_padding[0] - trim_padding[2] - (trim_border_width * 0.5)),
3007
+ prose_content_height: content_height - trim_line_metrics.padding_top - trim_line_metrics.padding_bottom,
3008
+ # NOTE content offset adjusts y position to account for border
3009
+ content_offset: (periphery == :footer ? trim_border_width * 0.5 : 0),
3010
+ }
3011
+ case trim_styles[:img_valign]
3012
+ when nil
3013
+ trim_styles[:img_valign] = trim_styles[:valign]
3014
+ when 'middle'
3015
+ trim_styles[:img_valign] = :center
3016
+ when 'top', 'center', 'bottom'
3017
+ trim_styles[:img_valign] = trim_styles[:img_valign].to_sym
3018
+ end
3019
+
3020
+ colspec_dict = PageSides.inject({}) do |acc, side|
3021
+ side_trim_content_width = trim_content_width[side]
3022
+ if (custom_colspecs = @theme[%(#{periphery}_#{side}_columns)] || @theme[%(#{periphery}_columns)])
3023
+ case (colspecs = (custom_colspecs.to_s.tr ',', ' ').split[0..2]).size
3024
+ when 3
3025
+ colspecs = { left: colspecs[0], center: colspecs[1], right: colspecs[2] }
3026
+ when 2
3027
+ colspecs = { left: colspecs[0], center: '0', right: colspecs[1] }
3028
+ when 0, 1
3029
+ colspecs = { left: '0', center: colspecs[0] || '100', right: '0' }
3030
+ end
3031
+ tot_width = 0
3032
+ side_colspecs = colspecs.map {|col, spec|
3033
+ if (alignment_char = spec.chr).to_i.to_s != alignment_char
3034
+ alignment = AlignmentTable[alignment_char] || :left
3035
+ rel_width = spec[1..-1].to_f
3036
+ else
3037
+ alignment = :left
3038
+ rel_width = spec.to_f
3039
+ end
3040
+ tot_width += rel_width
3041
+ [col, { align: alignment, width: rel_width, x: 0 }]
3042
+ }.to_h
3043
+ # QUESTION should we allow the columns to overlap (capping width at 100%)?
3044
+ side_colspecs.each {|_, colspec| colspec[:width] = (colspec[:width] / tot_width) * side_trim_content_width }
3045
+ side_colspecs[:right][:x] = (side_colspecs[:center][:x] = side_colspecs[:left][:width]) + side_colspecs[:center][:width]
3046
+ acc[side] = side_colspecs
3047
+ else
3048
+ acc[side] = {
3049
+ left: { align: :left, width: side_trim_content_width, x: 0 },
3050
+ center: { align: :center, width: side_trim_content_width, x: 0 },
3051
+ right: { align: :right, width: side_trim_content_width, x: 0 }
3052
+ }
3053
+ end
3054
+ acc
3055
+ end
3056
+
3057
+ content_dict = PageSides.inject({}) do |acc, side|
3058
+ side_content = {}
3059
+ ColumnPositions.each do |position|
3060
+ unless (val = @theme[%(#{periphery}_#{side}_#{position}_content)]).nil_or_empty?
3061
+ if (val.include? ':') && val =~ ImageAttributeValueRx
3062
+ # TODO support image URL
3063
+ if ::File.readable? (image_path = (ThemeLoader.resolve_theme_asset $1, @themesdir))
3064
+ image_attrs = (AttributeList.new $2).parse ['alt', 'width']
3065
+ image_opts = resolve_image_options image_path, image_attrs, container_size: [colspec_dict[side][position][:width], trim_styles[:content_height]], format: image_attrs['format']
3066
+ side_content[position] = [image_path, image_opts]
3067
+ else
3068
+ # NOTE allows inline image handler to report invalid reference and replace with alt text
3069
+ side_content[position] = %(image:#{image_path}[#{$2}])
3070
+ end
3071
+ else
3072
+ side_content[position] = val
3073
+ end
3074
+ end
3075
+ end
3076
+ # NOTE set fallbacks if not explicitly disabled
3077
+ if side_content.empty? && periphery == :footer && @theme[%(footer_#{side}_content)] != 'none'
3078
+ side_content = { side == :recto ? :right : :left => '{page-number}' }
3079
+ end
3080
+
3081
+ acc[side] = side_content
3082
+ acc
3083
+ end
3084
+
3085
+ if trim_styles[:bg_color] || trim_styles[:border_width] > 0
3086
+ stamp_names = { recto: %(#{layout}_#{periphery}_recto), verso: %(#{layout}_#{periphery}_verso) }
3087
+ PageSides.each do |side|
3088
+ create_stamp stamp_names[side] do
3089
+ canvas do
3090
+ if trim_styles[:bg_color]
3091
+ bounding_box [0, trim_styles[:top]], width: bounds.width, height: trim_styles[:height] do
3092
+ fill_bounds trim_styles[:bg_color]
3093
+ if trim_styles[:border_width] > 0
3094
+ # TODO stroke_horizontal_rule should support :at
3095
+ move_down bounds.height if periphery == :header
3096
+ stroke_horizontal_rule trim_styles[:border_color], line_width: trim_styles[:border_width], line_style: trim_styles[:border_style]
3097
+ end
3098
+ end
3099
+ else
3100
+ bounding_box [trim_styles[:left][side], trim_styles[:top]], width: trim_styles[:width][side], height: trim_styles[:height] do
3101
+ # TODO stroke_horizontal_rule should support :at
3102
+ move_down bounds.height if periphery == :header
3103
+ stroke_horizontal_rule trim_styles[:border_color], line_width: trim_styles[:border_width], line_style: trim_styles[:border_style]
3104
+ end
3105
+ end
3106
+ end
3107
+ end
3108
+ end
3109
+ end
3110
+
3111
+ [trim_styles, colspec_dict, content_dict, stamp_names]
3112
+ end
3113
+ end
3114
+
2972
3115
  def add_outline doc, num_levels = 2, toc_page_nums = [], num_front_matter_pages = 0
2973
3116
  front_matter_counter = RomanNumeral.new 0, :lower
2974
3117
  pagenum_labels = {}
@@ -2985,7 +3128,7 @@ class Converter < ::Prawn::Document
2985
3128
  outline.define do
2986
3129
  # FIXME use sanitize: :plain_text once available
2987
3130
  if (doctitle = document.sanitize(doc.doctitle use_fallback: true))
2988
- # FIXME link to title page if there's a cover page (skip cover page and ensuing blank page)
3131
+ # FIXME link to title page if there's a cover page (skip cover page and ensure blank page)
2989
3132
  page title: doctitle, destination: (document.dest_top 1)
2990
3133
  end
2991
3134
  page title: (doc.attr 'toc-title'), destination: (document.dest_top toc_page_nums.first) unless toc_page_nums.none?
@@ -3046,6 +3189,16 @@ class Converter < ::Prawn::Document
3046
3189
  @theme.svg_font_family || @theme.base_font_family
3047
3190
  end
3048
3191
 
3192
+ attr_reader :allow_uri_read
3193
+
3194
+ def resolve_text_transform key, use_fallback = true
3195
+ if (transform = ::Hash === key ? (key.delete :text_transform) : @theme[key.to_s])
3196
+ transform == 'none' ? nil : transform
3197
+ elsif use_fallback
3198
+ @text_transform
3199
+ end
3200
+ end
3201
+
3049
3202
  # QUESTION should we pass a category as an argument?
3050
3203
  # QUESTION should we make this a method on the theme ostruct? (e.g., @theme.resolve_color key, fallback)
3051
3204
  def resolve_theme_color key, fallback_color = nil
@@ -3119,7 +3272,7 @@ class Converter < ::Prawn::Document
3119
3272
  end
3120
3273
 
3121
3274
  prev_color, @font_color = @font_color, color if color
3122
- prev_transform, @text_transform = @text_transform, transform if transform
3275
+ prev_transform, @text_transform = @text_transform, (transform == 'none' ? nil : transform) if transform
3123
3276
 
3124
3277
  font family, size: size, style: (style && style.to_sym) do
3125
3278
  result = yield
@@ -3372,7 +3525,7 @@ class Converter < ::Prawn::Document
3372
3525
  doc = node.document
3373
3526
  imagesdir = relative_to_imagesdir ? (resolve_imagesdir doc) : nil
3374
3527
  image_path ||= node.attr 'target'
3375
- image_format ||= ::Asciidoctor::Image.format image_path, (::Asciidoctor::Image === node ? node : nil)
3528
+ image_format ||= ::Asciidoctor::Image.format image_path, (::Asciidoctor::Image === node ? node.attributes : nil)
3376
3529
  # NOTE currently used for inline images
3377
3530
  if ::Base64 === image_path
3378
3531
  tmp_image = ::Tempfile.create ['image-', image_format && %(.#{image_format})]
@@ -3388,7 +3541,7 @@ class Converter < ::Prawn::Document
3388
3541
  # handle case when image is a URI
3389
3542
  elsif (node.is_uri? image_path) || (imagesdir && (node.is_uri? imagesdir) &&
3390
3543
  (image_path = (node.normalize_web_path image_path, imagesdir, false)))
3391
- unless doc.attr? 'allow-uri-read'
3544
+ unless allow_uri_read
3392
3545
  logger.warn %(allow-uri-read is not enabled; cannot embed remote image: #{image_path}) unless scratch?
3393
3546
  return
3394
3547
  end
@@ -3413,34 +3566,89 @@ class Converter < ::Prawn::Document
3413
3566
  end
3414
3567
  end
3415
3568
 
3416
- # Resolve the path to the background image either from a document attribute or theme key.
3569
+ # Resolve the path and sizing of the background image either from a document attribute or theme key.
3417
3570
  #
3418
- # Returns The string "none" if the background image value is none, otherwise the resolved
3419
- # path to the image. If neither the document attribute or theme key are specified, or
3420
- # the image path cannot be resolved, return nil.
3571
+ # Returns the argument list for the image method if the document attribute or theme key is found. Otherwise,
3572
+ # nothing. The first argument in the argument list is the image path. If that value is nil, the background
3573
+ # image is disabled. The second argument is the options hash to specify the dimensions, such as width and fit.
3421
3574
  def resolve_background_image doc, theme, key
3422
- if (bg_image = (doc_attr_val = (doc.attr key)) || theme[(key.tr '-', '_').to_sym])
3423
- return bg_image if bg_image == 'none'
3424
-
3425
- if (bg_image.include? ':') && bg_image =~ ImageAttributeValueRx
3426
- # QUESTION should we support width and height in this case?
3427
- # TODO support explicit format
3428
- bg_image = $1
3429
- relative_to_imagesdir = true
3575
+ if (image_path = (doc.attr key) || (from_theme = theme[(key.tr '-', '_').to_sym]))
3576
+ if image_path == 'none'
3577
+ return []
3578
+ elsif (image_path.include? ':') && image_path =~ ImageAttributeValueRx
3579
+ image_attrs = (AttributeList.new $2).parse ['alt', 'width']
3580
+ # TODO support remote image when loaded from theme
3581
+ image_path = from_theme ? (ThemeLoader.resolve_theme_asset $1, @themesdir) : (resolve_image_path doc, $1, true, (image_format = image_attrs['format']))
3430
3582
  else
3431
- relative_to_imagesdir = false
3583
+ image_path = from_theme ? (ThemeLoader.resolve_theme_asset image_path, @themesdir) : (resolve_image_path doc, image_path, false)
3432
3584
  end
3433
3585
 
3434
- if (bg_image = doc_attr_val ? (resolve_image_path doc, bg_image, relative_to_imagesdir) :
3435
- (ThemeLoader.resolve_theme_asset bg_image, (doc.attr 'pdf-stylesdir')))
3436
- if ::File.readable? bg_image
3437
- bg_image
3438
- else
3439
- logger.warn %(#{key.tr '-', ' '} not found or readable: #{bg_image})
3440
- nil
3586
+ return unless image_path
3587
+
3588
+ unless ::File.readable? image_path
3589
+ logger.warn %(#{key.tr '-', ' '} not found or readable: #{image_path})
3590
+ return
3591
+ end
3592
+
3593
+ [image_path, (resolve_image_options image_path, image_attrs, background: true, format: image_format)]
3594
+ end
3595
+ end
3596
+
3597
+ def resolve_image_options image_path, image_attrs, opts = {}
3598
+ if (image_format = opts[:format] || (::Asciidoctor::Image.format image_path)) == 'svg'
3599
+ image_opts = {
3600
+ enable_file_requests_with_root: (::File.dirname image_path),
3601
+ enable_web_requests: allow_uri_read,
3602
+ fallback_font_name: default_svg_font,
3603
+ format: 'svg',
3604
+ }
3605
+ else
3606
+ image_opts = {}
3607
+ end
3608
+ background = opts[:background]
3609
+ container_size = opts.fetch :container_size, (background ? [page_width, page_height] : [bounds.width, bounds.height])
3610
+ if image_attrs
3611
+ if background && (image_pos = image_attrs['position']) && (image_pos = resolve_background_position image_pos, nil)
3612
+ image_opts.update image_pos
3613
+ end
3614
+ if (image_fit = image_attrs['fit'])
3615
+ container_width, container_height = container_size
3616
+ case image_fit
3617
+ when 'none'
3618
+ if (image_width = resolve_explicit_width image_attrs, container_width)
3619
+ image_opts[:width] = image_width
3620
+ end
3621
+ when 'scale-down'
3622
+ # NOTE if width and height aren't set in SVG, real width and height are computed after stretching viewbox to fit page
3623
+ if (image_width = resolve_explicit_width image_attrs, container_width) && image_width > container_width
3624
+ image_opts[:fit] = container_size
3625
+ elsif (image_size = intrinsic_image_dimensions image_path, image_format) &&
3626
+ (image_width ? image_width * (image_size[:height] / image_size[:width]) > container_height : (to_pt image_size[:width], :px) > container_width || (to_pt image_size[:height], :px) > container_height)
3627
+ image_opts[:fit] = container_size
3628
+ elsif image_width
3629
+ image_opts[:width] = image_width
3630
+ end
3631
+ when 'cover'
3632
+ # QUESTION should we take explicit width into account?
3633
+ if (image_size = intrinsic_image_dimensions image_path, image_format)
3634
+ if container_width * (image_size[:height] / image_size[:width]) < container_height
3635
+ image_opts[:height] = container_height
3636
+ else
3637
+ image_opts[:width] = container_width
3638
+ end
3639
+ end
3640
+ else # contain
3641
+ image_opts[:fit] = container_size
3441
3642
  end
3643
+ elsif (image_width = resolve_explicit_width image_attrs, container_size[0])
3644
+ image_opts[:width] = image_width
3645
+ else # default to fit=contain if sizing is not specified
3646
+ image_opts[:fit] = container_size
3442
3647
  end
3648
+ else
3649
+ image_opts[:fit] = container_size
3443
3650
  end
3651
+ image_opts
3444
3652
  end
3445
3653
 
3446
3654
  # Resolves the explicit width as a PDF pt value if the value is specified in
@@ -3495,7 +3703,39 @@ class Converter < ::Prawn::Document
3495
3703
  end
3496
3704
  elsif attrs.key? 'width'
3497
3705
  # QUESTION should we honor percentage width value?
3498
- [max_width, (to_pt attrs['width'].to_f, :px)].min
3706
+ width = to_pt attrs['width'].to_f, :px
3707
+ opts[:constrain_to_bounds] ? [max_width, width].min : width
3708
+ end
3709
+ end
3710
+
3711
+ def resolve_background_position value, default_value = {}
3712
+ if value.include? ' '
3713
+ result = {}
3714
+ center = nil
3715
+ (value.split ' ', 2).each do |keyword|
3716
+ if keyword == 'left' || keyword == 'right'
3717
+ result[:position] = keyword.to_sym
3718
+ elsif keyword == 'top' || keyword == 'bottom'
3719
+ result[:vposition] = keyword.to_sym
3720
+ elsif keyword == 'center'
3721
+ center = true
3722
+ end
3723
+ end
3724
+ if center
3725
+ result[:position] ||= :center
3726
+ result[:vposition] ||= :center
3727
+ result
3728
+ elsif (result.key? :position) && (result.key? :vposition)
3729
+ result
3730
+ else
3731
+ default_value
3732
+ end
3733
+ elsif value == 'left' || value == 'right' || value == 'center'
3734
+ { position: value.to_sym, vposition: :center }
3735
+ elsif value == 'top' || value == 'bottom'
3736
+ { position: :center, vposition: value.to_sym }
3737
+ else
3738
+ default_value
3499
3739
  end
3500
3740
  end
3501
3741
 
@@ -3540,6 +3780,8 @@ class Converter < ::Prawn::Document
3540
3780
 
3541
3781
  # QUESTION move to prawn/extensions.rb?
3542
3782
  def init_scratch_prototype
3783
+ @save_state = nil
3784
+ @scratch_depth = 0
3543
3785
  # IMPORTANT don't set font before using Marshal, it causes serialization to fail
3544
3786
  @prototype = ::Marshal.load ::Marshal.dump self
3545
3787
  @prototype.state.store.info.data[:Scratch] = true
@@ -3547,6 +3789,23 @@ class Converter < ::Prawn::Document
3547
3789
  #@prototype.start_new_page if @prototype.page_number == 0
3548
3790
  end
3549
3791
 
3792
+ def push_scratch doc
3793
+ if (@scratch_depth += 1) == 1
3794
+ @save_state = {
3795
+ catalog: {}.tap {|accum| doc.catalog.each {|k, v| accum[k] = v.dup } },
3796
+ attributes: doc.attributes.dup,
3797
+ }
3798
+ end
3799
+ end
3800
+
3801
+ def pop_scratch doc
3802
+ if (@scratch_depth -= 1) == 0
3803
+ doc.catalog.replace @save_state[:catalog]
3804
+ doc.attributes.replace @save_state[:attributes]
3805
+ @save_state = nil
3806
+ end
3807
+ end
3808
+
3550
3809
  =begin
3551
3810
  # TODO could assign pdf-anchor attributes here too
3552
3811
  def assign_missing_section_ids doc