asciidoctor-pdf 2.0.0.beta.2 → 2.0.0.rc.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -156,30 +156,6 @@ module Asciidoctor
156
156
  ::Asciidoctor::Inline === node ? result : self
157
157
  end
158
158
 
159
- def traverse node, opts = {}
160
- # NOTE: need to reconfigure document to use scratch converter in scratch document
161
- if self == (prev_converter = node.document.converter)
162
- prev_converter = nil
163
- else
164
- node.document.instance_variable_set :@converter, self
165
- end
166
- if node.blocks?
167
- node.content
168
- elsif node.content_model != :compound && (string = node.content)
169
- prose_opts = opts.merge hyphenate: true, margin_bottom: 0
170
- if (bottom_gutter = @bottom_gutters[-1][node])
171
- prose_opts[:bottom_gutter] = bottom_gutter
172
- end
173
- ink_prose string, prose_opts
174
- end
175
- ensure
176
- node.document.instance_variable_set :@converter, prev_converter if prev_converter
177
- end
178
-
179
- def log severity, message = nil, &block
180
- logger.send severity, message, &block unless scratch?
181
- end
182
-
183
159
  def convert_document doc
184
160
  doc.promote_preface_block
185
161
  init_pdf doc
@@ -195,15 +171,18 @@ module Asciidoctor
195
171
  # NOTE: a new page will already be started (page_number = 2) if the front cover image is a PDF
196
172
  ink_cover_page doc, :front
197
173
  has_front_cover = page_number > marked_page_number
198
- has_title_page = ink_title_page doc if (title_page_on = doc.doctype == 'book' || (doc.attr? 'title-page'))
199
-
200
- @page_margin_by_side[:cover] = @page_margin_by_side[:recto] if @media == 'prepress' && page_number == 0
201
-
202
- start_new_page unless page&.empty? # rubocop:disable Lint/SafeNavigationWithEmpty
203
-
204
- # NOTE: the base font must be set before any content is written to the main or scratch document
205
- # this method is called inside ink_title_page if the title page is active
206
- font @theme.base_font_family, size: @root_font_size, style: @theme.base_font_style unless has_title_page
174
+ if (has_title_page = (title_page_on = doc.doctype == 'book' || (doc.attr? 'title-page')) && (start_title_page doc))
175
+ # NOTE: the base font must be set before any content is written to the main or scratch document
176
+ font @theme.base_font_family, size: @root_font_size, style: @theme.base_font_style
177
+ ink_title_page doc
178
+ start_new_page
179
+ else
180
+ @page_margin_by_side[:cover] = @page_margin_by_side[:recto] if @media == 'prepress' && page_number == 0
181
+ start_new_page unless page&.empty? # rubocop:disable Lint/SafeNavigationWithEmpty
182
+ # NOTE: the base font must be set before any content is written to the main or scratch document
183
+ # this method is called inside ink_title_page if the title page is active
184
+ font @theme.base_font_family, size: @root_font_size, style: @theme.base_font_style
185
+ end
207
186
 
208
187
  unless title_page_on
209
188
  body_start_page_number = page_number
@@ -217,14 +196,14 @@ module Asciidoctor
217
196
  indent_section do
218
197
  toc_num_levels = (doc.attr 'toclevels', 2).to_i
219
198
  if (insert_toc = (doc.attr? 'toc') && !((toc_placement = doc.attr 'toc-placement') == 'macro' || toc_placement == 'preamble') && doc.sections?)
220
- start_new_page if @ppbook && verso_page?
199
+ start_toc_page doc, toc_placement if title_page_on
221
200
  add_dest_for_block doc, id: 'toc', y: (at_page_top? ? page_height : nil)
222
201
  @toc_extent = allocate_toc doc, toc_num_levels, cursor, title_page_on
223
202
  else
224
203
  @toc_extent = nil
225
204
  end
226
205
 
227
- start_new_page if @ppbook && verso_page? && !(((next_block = doc.blocks[0])&.context == :preamble ? next_block.blocks[0] : next_block)&.option? 'nonfacing')
206
+ start_new_page if @ppbook && verso_page? && !(((next_block = doc.first_child)&.context == :preamble ? next_block.first_child : next_block)&.option? 'nonfacing')
228
207
 
229
208
  if title_page_on
230
209
  zero_page_offset = has_front_cover ? 1 : 0
@@ -383,7 +362,7 @@ module Asciidoctor
383
362
  @ppbook = nil
384
363
  end
385
364
  # QUESTION: should ThemeLoader handle registering fonts instead?
386
- register_fonts theme.font_catalog, (doc.attr 'pdf-fontsdir', 'GEM_FONTS_DIR')
365
+ register_fonts theme.font_catalog, ((doc.attr 'pdf-fontsdir')&.sub '{docdir}', (doc.attr 'docdir')) || 'GEM_FONTS_DIR'
387
366
  default_kerning theme.base_font_kerning != 'none'
388
367
  @fallback_fonts = Array theme.font_fallbacks
389
368
  @allow_uri_read = doc.attr? 'allow-uri-read'
@@ -405,12 +384,13 @@ module Asciidoctor
405
384
  @font_scale = 1
406
385
  @font_color = theme.base_font_color
407
386
  @text_decoration_width = theme.base_text_decoration_width
408
- @base_text_align = (align = doc.attr 'text-align') && (TextAlignmentNames.include? align) ? align : theme.base_text_align
387
+ @base_text_align = (text_align = doc.attr 'text-align') && (TextAlignmentNames.include? text_align) ? text_align : theme.base_text_align
409
388
  @base_line_height = theme.base_line_height
410
389
  @cjk_line_breaks = doc.attr? 'scripts', 'cjk'
411
- if (hyphen_lang = doc.attr 'hyphens') &&
390
+ if (hyphen_lang = (doc.attr 'hyphens') ||
391
+ (((doc.attribute_locked? 'hyphens') || ((doc.instance_variable_get :@attributes_modified).include? 'hyphens')) ? nil : @theme.base_hyphens)) &&
412
392
  ((defined? ::Text::Hyphen::VERSION) || !(Helpers.require_library 'text/hyphen', 'text-hyphen', :warn).nil?)
413
- hyphen_lang = doc.attr 'lang' if hyphen_lang.empty?
393
+ hyphen_lang = doc.attr 'lang' if !(::String === hyphen_lang) || hyphen_lang.empty?
414
394
  hyphen_lang = 'en_us' if hyphen_lang.nil_or_empty? || hyphen_lang == 'en'
415
395
  hyphen_lang = (hyphen_lang.tr '-', '_').downcase
416
396
  @hyphenator = ::Text::Hyphen.new language: hyphen_lang
@@ -434,75 +414,10 @@ module Asciidoctor
434
414
  if (@optimize = doc.attr 'optimize')
435
415
  @optimize = nil unless (defined? ::Asciidoctor::PDF::Optimizer) || !(Helpers.require_library OptimizerRequirePath, 'rghost', :warn).nil? # rubocop:disable Style/SoleNestedConditional
436
416
  end
437
- allocate_prototype
417
+ allocate_scratch_prototype
438
418
  self
439
419
  end
440
420
 
441
- def load_theme doc
442
- @theme ||= begin # rubocop:disable Naming/MemoizedInstanceVariableName
443
- if (theme = doc.options[:pdf_theme])
444
- theme = theme.dup
445
- @themesdir = ::File.expand_path theme.__dir__ || (doc.attr 'pdf-themesdir') || ::Dir.pwd
446
- elsif (theme_name = doc.attr 'pdf-theme')
447
- theme = ThemeLoader.load_theme theme_name, (user_themesdir = doc.attr 'pdf-themesdir')
448
- @themesdir = theme.__dir__
449
- else
450
- @themesdir = (theme = ThemeLoader.load_theme).__dir__
451
- end
452
- prepare_theme theme
453
- rescue
454
- if user_themesdir
455
- message = %(could not locate or load the pdf theme `#{theme_name}' in #{user_themesdir})
456
- else
457
- message = %(could not locate or load the built-in pdf theme `#{theme_name}')
458
- end
459
- message += %( because of #{$!.class} #{$!.message})
460
- log :error, (message.sub %r/$/, '; reverting to default theme')
461
- @themesdir = (theme = ThemeLoader.load_theme).__dir__
462
- prepare_theme theme
463
- end
464
- end
465
-
466
- def prepare_theme theme
467
- theme.base_font_color ||= '000000'
468
- theme.base_font_size ||= 12
469
- theme.base_font_style = theme.base_font_style&.to_sym || :normal
470
- theme.page_numbering_start_at ||= 'body'
471
- theme.running_content_start_at ||= 'body'
472
- theme.heading_margin_page_top ||= 0
473
- theme.heading_margin_top ||= 0
474
- theme.heading_margin_bottom ||= 0
475
- theme.prose_text_indent ||= 0
476
- theme.prose_text_indent_inner ||= 0
477
- theme.prose_margin_bottom ||= 0
478
- theme.block_margin_bottom ||= 0
479
- theme.list_indent ||= 0
480
- theme.list_item_spacing ||= 0
481
- theme.description_list_term_spacing ||= 0
482
- theme.description_list_description_indent ||= 0
483
- theme.table_border_color ||= (theme.base_border_color || '000000')
484
- theme.table_border_width ||= 0.5
485
- theme.thematic_break_border_color ||= (theme.base_border_color || '000000')
486
- theme.image_border_width ||= 0
487
- theme.code_linenum_font_color ||= '999999'
488
- theme.callout_list_margin_top_after_code ||= 0
489
- theme.role_unresolved_font_color ||= 'FF0000'
490
- theme.footnotes_item_spacing ||= 0
491
- theme.index_columns ||= 2
492
- theme.index_column_gap ||= theme.base_font_size
493
- theme.kbd_separator ||= '+'
494
- theme.title_page_authors_delimiter ||= ', '
495
- theme.title_page_revision_delimiter ||= ', '
496
- theme.toc_indent ||= 0
497
- theme.toc_hanging_indent ||= 0
498
- if ::Array === (quotes = theme.quotes)
499
- TypographicQuotes.each_with_index {|char, idx| quotes[idx] ||= char }
500
- else
501
- theme.quotes = TypographicQuotes
502
- end
503
- theme
504
- end
505
-
506
421
  def build_pdf_options doc, theme
507
422
  case (page_margin = (doc.attr 'pdf-page-margin') || theme.page_margin)
508
423
  when ::Array
@@ -620,35 +535,86 @@ module Asciidoctor
620
535
  info
621
536
  end
622
537
 
623
- # NOTE: init_page is called within a float context; this will suppress prawn-svg messing with the cursor
624
- # NOTE: init_page is not called for imported pages, front and back cover pages, and other image pages
625
- def init_page *_args
626
- next_page_side = page_side nil, @folio_placement[:inverted]
627
- if @media == 'prepress' && (next_page_margin = @page_margin_by_side[page_number == 1 ? :cover : next_page_side]) != page_margin
628
- set_page_margin next_page_margin
538
+ def load_theme doc
539
+ @theme ||= begin # rubocop:disable Naming/MemoizedInstanceVariableName
540
+ if (theme = doc.options[:pdf_theme])
541
+ theme = theme.dup
542
+ @themesdir = ::File.expand_path theme.__dir__ ||
543
+ (user_themesdir = ((doc.attr 'pdf-themesdir')&.sub '{docdir}', (doc.attr 'docdir')) || ::Dir.pwd)
544
+ elsif (theme_name = doc.attr 'pdf-theme')
545
+ theme = ThemeLoader.load_theme theme_name, (user_themesdir = (doc.attr 'pdf-themesdir')&.sub '{docdir}', (doc.attr 'docdir'))
546
+ @themesdir = theme.__dir__
547
+ else
548
+ @themesdir = (theme = ThemeLoader.load_theme).__dir__
549
+ end
550
+ prepare_theme theme
551
+ rescue
552
+ if user_themesdir
553
+ message = %(could not locate or load the pdf theme `#{theme_name}' in #{user_themesdir})
554
+ else
555
+ message = %(could not locate or load the built-in pdf theme `#{theme_name}')
556
+ end
557
+ message += %( because of #{$!.class} #{$!.message})
558
+ log :error, (message.sub %r/$/, '; reverting to default theme')
559
+ @themesdir = (theme = ThemeLoader.load_theme).__dir__
560
+ prepare_theme theme
629
561
  end
630
- unless @page_bg_color == 'FFFFFF'
631
- tare = true
632
- fill_absolute_bounds @page_bg_color
562
+ end
563
+
564
+ def prepare_theme theme
565
+ theme.base_font_color ||= '000000'
566
+ theme.base_font_size ||= 12
567
+ theme.base_font_style = theme.base_font_style&.to_sym || :normal
568
+ theme.page_numbering_start_at ||= 'body'
569
+ theme.running_content_start_at ||= 'body'
570
+ theme.heading_margin_page_top ||= 0
571
+ theme.heading_margin_top ||= 0
572
+ theme.heading_margin_bottom ||= 0
573
+ theme.prose_text_indent ||= 0
574
+ theme.prose_text_indent_inner ||= 0
575
+ theme.prose_margin_bottom ||= 0
576
+ theme.block_margin_bottom ||= 0
577
+ theme.list_indent ||= 0
578
+ theme.list_item_spacing ||= 0
579
+ theme.description_list_term_spacing ||= 0
580
+ theme.description_list_description_indent ||= 0
581
+ theme.table_border_color ||= (theme.base_border_color || '000000')
582
+ theme.table_border_width ||= 0.5
583
+ theme.thematic_break_border_color ||= (theme.base_border_color || '000000')
584
+ theme.image_border_width ||= 0
585
+ theme.code_linenum_font_color ||= '999999'
586
+ theme.callout_list_margin_top_after_code ||= 0
587
+ theme.role_unresolved_font_color ||= 'FF0000'
588
+ theme.footnotes_item_spacing ||= 0
589
+ theme.index_columns ||= 2
590
+ theme.index_column_gap ||= theme.base_font_size
591
+ theme.kbd_separator ||= '+'
592
+ theme.title_page_authors_delimiter ||= ', '
593
+ theme.title_page_revision_delimiter ||= ', '
594
+ theme.toc_indent ||= 0
595
+ theme.toc_hanging_indent ||= 0
596
+ if ::Array === (quotes = theme.quotes)
597
+ TypographicQuotes.each_with_index {|char, idx| quotes[idx] ||= char }
598
+ else
599
+ theme.quotes = TypographicQuotes
633
600
  end
634
- if (bg_image_path, bg_image_opts = @page_bg_image[next_page_side])
635
- tare = true
636
- begin
637
- if bg_image_opts[:format] == 'pdf'
638
- # NOTE: pages that use PDF for the background do not support a background color or running content
639
- # IMPORTANT: the background PDF must have the same dimensions as the current PDF
640
- import_page bg_image_path, (bg_image_opts.merge replace: true, advance: false, advance_if_missing: false)
641
- else
642
- canvas { image bg_image_path, ({ position: :center, vposition: :center }.merge bg_image_opts) }
643
- end
644
- rescue
645
- facing_page_side = (PageSides - [next_page_side])[0]
646
- @page_bg_image[facing_page_side] = nil if @page_bg_image[facing_page_side] == @page_bg_image[next_page_side]
647
- @page_bg_image[next_page_side] = nil
648
- log :warn, %(could not embed page background image: #{bg_image_path}; #{$!.message})
649
- end
601
+ theme
602
+ end
603
+
604
+ def indent_section
605
+ if (values = @section_indent)
606
+ indent(values[0], values[1]) { yield }
607
+ else
608
+ yield
609
+ end
610
+ end
611
+
612
+ def outdent_section enabled = true
613
+ if enabled && (values = @section_indent)
614
+ indent(-values[0], -values[1]) { yield }
615
+ else
616
+ yield
650
617
  end
651
- page.tare_content_stream if tare
652
618
  end
653
619
 
654
620
  def convert_section sect, _opts = {}
@@ -657,7 +623,7 @@ module Asciidoctor
657
623
  sect.context = :open
658
624
  return convert_abstract sect
659
625
  elsif (index_section = sectname == 'index') && @index.empty?
660
- sect.parent.blocks.delete sect
626
+ sect.remove
661
627
  return
662
628
  end
663
629
 
@@ -668,10 +634,10 @@ module Asciidoctor
668
634
  title = %(#{title}\n<em class="subtitle">#{subtitle}</em>)
669
635
  end
670
636
  hlevel = sect.level.next
671
- align = (@theme[%(heading_h#{hlevel}_text_align)] || @theme.heading_text_align || @base_text_align).to_sym
637
+ text_align = (@theme[%(heading_h#{hlevel}_text_align)] || @theme.heading_text_align || @base_text_align).to_sym
672
638
  chapterlike = !(part = sectname == 'part') && (sectname == 'chapter' || (sect.document.doctype == 'book' && sect.level == 1))
673
639
  hidden = sect.option? 'notitle'
674
- hopts = { align: align, level: hlevel, part: part, chapterlike: chapterlike, outdent: !(part || chapterlike) }
640
+ hopts = { align: text_align, level: hlevel, part: part, chapterlike: chapterlike, outdent: !(part || chapterlike) }
675
641
  if part
676
642
  unless @theme.heading_part_break_before == 'auto'
677
643
  started_new = true
@@ -684,7 +650,7 @@ module Asciidoctor
684
650
  start_new_chapter sect
685
651
  end
686
652
  end
687
- arrange_heading sect, title, hopts unless hidden || started_new || at_page_top? || !sect.blocks?
653
+ arrange_heading sect, title, hopts unless hidden || started_new || at_page_top? || sect.empty?
688
654
  # QUESTION: should we store pdf-page-start, pdf-anchor & pdf-destination in internal map?
689
655
  sect.set_attr 'pdf-page-start', (start_pgnum = page_number)
690
656
  # QUESTION: should we just assign the section this generated id?
@@ -710,58 +676,14 @@ module Asciidoctor
710
676
  sect.set_attr 'pdf-page-end', page_number
711
677
  end
712
678
 
713
- def indent_section
714
- if (values = @section_indent)
715
- indent(values[0], values[1]) { yield }
716
- else
717
- yield
718
- end
719
- end
720
-
721
- def outdent_section enabled = true
722
- if enabled && (values = @section_indent)
723
- indent(-values[0], -values[1]) { yield }
724
- else
725
- yield
726
- end
727
- end
728
-
729
- # QUESTION: if a footnote ref appears in a separate chapter, should the footnote def be duplicated?
730
- def ink_footnotes node
731
- return if (fns = (doc = node.document).footnotes - @rendered_footnotes).empty?
732
- theme_margin :block, :bottom if node.context == :document || node == node.document.blocks[-1]
733
- theme_margin :footnotes, :top
734
- with_dry_run do |extent|
735
- if (single_page_height = extent&.single_page_height) && (delta = cursor - single_page_height - 0.0001) > 0
736
- move_down delta
737
- end
738
- theme_font :footnotes do
739
- (title = doc.attr 'footnotes-title') && (ink_caption title, category: :footnotes)
740
- item_spacing = @theme.footnotes_item_spacing
741
- index_offset = @rendered_footnotes.length
742
- sect_xreftext = node.context == :section && (node.xreftext node.document.attr 'xrefstyle')
743
- fns.each do |fn|
744
- label = (index = fn.index) - index_offset
745
- if sect_xreftext
746
- fn.singleton_class.send :attr_accessor, :label unless fn.respond_to? :label=
747
- fn.label = %(#{label} - #{sect_xreftext})
748
- end
749
- ink_prose %(<a id="_footnotedef_#{index}">#{DummyText}</a>[<a anchor="_footnoteref_#{index}">#{label}</a>] #{fn.text}), margin_bottom: item_spacing, hyphenate: true
750
- end
751
- @rendered_footnotes += fns if extent
752
- end
753
- end
754
- nil
755
- end
756
-
757
679
  def convert_floating_title node
758
680
  title = node.title
759
681
  hlevel = node.level.next
760
- unless (align = resolve_text_align_from_role node.roles)
761
- align = (@theme[%(heading_h#{hlevel}_text_align)] || @theme.heading_text_align || @base_text_align).to_sym
682
+ unless (text_align = resolve_text_align_from_role node.roles)
683
+ text_align = (@theme[%(heading_h#{hlevel}_text_align)] || @theme.heading_text_align || @base_text_align).to_sym
762
684
  end
763
- hopts = { align: align, level: hlevel, outdent: (parent = node.parent).context == :section }
764
- arrange_heading node, title, hopts unless at_page_top? || node == parent.blocks[-1]
685
+ hopts = { align: text_align, level: hlevel, outdent: node.parent.context == :section }
686
+ arrange_heading node, title, hopts unless at_page_top? || node.last_child?
765
687
  add_dest_for_block node if node.id
766
688
  # QUESTION: should we decouple styles from section titles?
767
689
  theme_font :heading, level: hlevel do
@@ -769,26 +691,84 @@ module Asciidoctor
769
691
  end
770
692
  end
771
693
 
772
- def convert_abstract node
773
- add_dest_for_block node if node.id
774
- outdent_section do
775
- pad_box @theme.abstract_padding do
776
- theme_font :abstract_title do
777
- ink_prose node.title, align: (@theme.abstract_title_text_align || @base_text_align).to_sym, margin_top: @theme.heading_margin_top, margin_bottom: @theme.heading_margin_bottom, line_height: (@theme.heading_line_height || @theme.base_line_height)
778
- end if node.title?
779
- theme_font :abstract do
780
- prose_opts = { align: (@theme.abstract_text_align || @base_text_align).to_sym, hyphenate: true }
781
- if (text_indent = @theme.prose_text_indent) > 0
782
- prose_opts[:indent_paragraphs] = text_indent
783
- end
784
- # FIXME: allow theme to control more first line options
785
- if (line1_font_style = @theme.abstract_first_line_font_style&.to_sym) && line1_font_style != font_style
786
- case line1_font_style
787
- when :normal
788
- first_line_options = { styles: [] }
789
- when :normal_italic
790
- first_line_options = { styles: [:italic] }
791
- else
694
+ def convert_index_section node
695
+ space_needed_for_category = @theme.description_list_term_spacing + (2 * (height_of_typeset_text 'A'))
696
+ pagenum_sequence_style = node.document.attr 'index-pagenum-sequence-style'
697
+ column_box [0, cursor], columns: @theme.index_columns, width: bounds.width, reflow_margins: true, spacer: @theme.index_column_gap do
698
+ @index.categories.each do |category|
699
+ bounds.move_past_bottom if space_needed_for_category > cursor
700
+ ink_prose category.name,
701
+ align: :left,
702
+ inline_format: false,
703
+ margin_bottom: @theme.description_list_term_spacing,
704
+ style: @theme.description_list_term_font_style&.to_sym
705
+ category.terms.each {|term| convert_index_list_item term, pagenum_sequence_style }
706
+ @theme.prose_margin_bottom > cursor ? bounds.move_past_bottom : (move_down @theme.prose_margin_bottom)
707
+ end
708
+ end
709
+ nil
710
+ end
711
+
712
+ def convert_index_list_item term, pagenum_sequence_style = nil
713
+ text = escape_xml term.name
714
+ unless term.container?
715
+ if @media == 'screen'
716
+ case pagenum_sequence_style
717
+ when 'page'
718
+ pagenums = term.dests.uniq {|dest| dest[:page] }.map {|dest| %(<a anchor="#{dest[:anchor]}">#{dest[:page]}</a>) }
719
+ when 'range'
720
+ first_anchor_per_page = term.dests.each_with_object({}) {|dest, accum| accum[dest[:page]] ||= dest[:anchor] }
721
+ pagenums = (consolidate_ranges first_anchor_per_page.keys).map do |range|
722
+ anchor = first_anchor_per_page[(range.include? '-') ? (range.partition '-')[0] : range]
723
+ %(<a anchor="#{anchor}">#{range}</a>)
724
+ end
725
+ else # term
726
+ pagenums = term.dests.map {|dest| %(<a anchor="#{dest[:anchor]}">#{dest[:page]}</a>) }
727
+ end
728
+ else
729
+ pagenums = consolidate_ranges term.dests.map {|dest| dest[:page] }.uniq
730
+ end
731
+ text = %(#{text}, #{pagenums.join ', '})
732
+ end
733
+ subterm_indent = @theme.description_list_description_indent
734
+ ink_prose text, align: :left, margin: 0, hanging_indent: subterm_indent * 2
735
+ indent subterm_indent do
736
+ term.subterms.each do |subterm|
737
+ convert_index_list_item subterm, pagenum_sequence_style
738
+ end
739
+ end unless term.leaf?
740
+ end
741
+
742
+ def convert_preamble node
743
+ # FIXME: core should not be promoting paragraph to preamble if there are no sections
744
+ if (first_block = node.first_child)&.context == :paragraph && node.document.sections? && !first_block.role?
745
+ first_block.role = 'lead'
746
+ end
747
+ traverse node
748
+ theme_margin :block, :bottom, (next_enclosed_block node)
749
+ convert_toc node, placement: 'preamble'
750
+ end
751
+
752
+ def convert_abstract node
753
+ add_dest_for_block node if node.id
754
+ outdent_section do
755
+ pad_box @theme.abstract_padding do
756
+ theme_font :abstract_title do
757
+ ink_prose node.title, align: (@theme.abstract_title_text_align || @base_text_align).to_sym, margin_top: @theme.heading_margin_top, margin_bottom: @theme.heading_margin_bottom, line_height: (@theme.heading_line_height || @theme.base_line_height)
758
+ end if node.title?
759
+ theme_font :abstract do
760
+ prose_opts = { align: (@theme.abstract_text_align || @base_text_align).to_sym, hyphenate: true }
761
+ if (text_indent = @theme.prose_text_indent) > 0
762
+ prose_opts[:indent_paragraphs] = text_indent
763
+ end
764
+ # FIXME: allow theme to control more first line options
765
+ if (line1_font_style = @theme.abstract_first_line_font_style&.to_sym) && line1_font_style != font_style
766
+ case line1_font_style
767
+ when :normal
768
+ first_line_options = { styles: [] }
769
+ when :normal_italic
770
+ first_line_options = { styles: [:italic] }
771
+ else
792
772
  first_line_options = { styles: [font_style, line1_font_style] }
793
773
  end
794
774
  end
@@ -801,12 +781,12 @@ module Asciidoctor
801
781
  prose_opts[:first_line_options] = first_line_options if first_line_options
802
782
  # FIXME: make this cleaner!!
803
783
  if node.blocks?
804
- last_block = (blocks = node.blocks)[-1]
805
- blocks.each do |child|
784
+ last_block = node.last_child
785
+ node.blocks.each do |child|
806
786
  if child.context == :paragraph
807
787
  child.document.playback_attributes child.attributes
808
788
  prose_opts[:margin_bottom] = 0 if child == last_block
809
- ink_prose child.content, ((align = resolve_text_align_from_role child.roles) ? (prose_opts.merge align: align) : prose_opts.dup)
789
+ ink_prose child.content, ((text_align = resolve_text_align_from_role child.roles) ? (prose_opts.merge align: text_align) : prose_opts.dup)
810
790
  prose_opts.delete :first_line_options
811
791
  prose_opts.delete :margin_bottom
812
792
  else
@@ -815,8 +795,8 @@ module Asciidoctor
815
795
  end
816
796
  end
817
797
  elsif node.content_model != :compound && (string = node.content)
818
- if (align = resolve_text_align_from_role node.roles)
819
- prose_opts[:align] = align
798
+ if (text_align = resolve_text_align_from_role node.roles)
799
+ prose_opts[:align] = text_align
820
800
  end
821
801
  ink_prose string, (prose_opts.merge margin_bottom: 0)
822
802
  end
@@ -827,27 +807,16 @@ module Asciidoctor
827
807
  theme_margin :block, :bottom, (next_enclosed_block node)
828
808
  end
829
809
 
830
- def convert_preamble node
831
- # FIXME: core should not be promoting paragraph to preamble if there are no sections
832
- if node.blocks? && (first_block = node.blocks[0]).context == :paragraph && node.document.sections? && !first_block.role?
833
- first_block.role = 'lead'
834
- end
835
- traverse node
836
- theme_margin :block, :bottom, (next_enclosed_block node)
837
- convert_toc node, placement: 'preamble'
838
- end
839
-
840
810
  def convert_paragraph node
841
811
  add_dest_for_block node if node.id
842
812
 
843
813
  prose_opts = { margin_bottom: 0, hyphenate: true }
844
- if (align = resolve_text_align_from_role (roles = node.roles), query_theme: true, remove_predefined: true)
845
- prose_opts[:align] = align
814
+ if (text_align = resolve_text_align_from_role (roles = node.roles), query_theme: true, remove_predefined: true)
815
+ prose_opts[:align] = text_align
846
816
  end
847
817
  role_keys = roles.map {|role| %(role_#{role}).to_sym } unless roles.empty?
848
818
  if (text_indent = @theme.prose_text_indent) > 0 ||
849
- ((text_indent = @theme.prose_text_indent_inner) > 0 &&
850
- (self_idx = (siblings = node.parent.blocks).index node) > 0 && siblings[self_idx - 1].context == :paragraph)
819
+ ((text_indent = @theme.prose_text_indent_inner) > 0 && node.previous_sibling&.context == :paragraph)
851
820
  prose_opts[:indent_paragraphs] = text_indent
852
821
  end
853
822
  if (bottom_gutter = @bottom_gutters[-1][node])
@@ -877,7 +846,7 @@ module Asciidoctor
877
846
 
878
847
  def convert_admonition node
879
848
  type = node.attr 'name'
880
- label_align = @theme.admonition_label_text_align&.to_sym || :center
849
+ label_text_align = @theme.admonition_label_text_align&.to_sym || :center
881
850
  # TODO: allow vertical_align to be a number
882
851
  if (label_valign = @theme.admonition_label_vertical_align&.to_sym || :middle) == :middle
883
852
  label_valign = :center
@@ -893,14 +862,14 @@ module Asciidoctor
893
862
  icon_size = icon_data[:size] || 24
894
863
  label_width = label_min_width || (icon_size * 1.5)
895
864
  elsif (icon_path = has_icon || !(icon_path = (@theme[%(admonition_icon_#{type})] || {})[:image]) ?
896
- (resolve_icon_image_path node, type) :
865
+ (get_icon_image_path node, type) :
897
866
  (ThemeLoader.resolve_theme_asset (apply_subs_discretely doc, icon_path, subs: [:attributes]), @themesdir)) &&
898
867
  (::File.readable? icon_path)
899
868
  icons = true
900
869
  # TODO: introduce @theme.admonition_image_width? or use size key from admonition_icon_<name>?
901
870
  label_width = label_min_width || 36.0
902
871
  else
903
- log :warn, %(admonition icon image#{has_icon ? '' : ' for ' + type.upcase} not found or not readable: #{icon_path || (resolve_icon_image_path node, type, false)})
872
+ log :warn, %(admonition icon image#{has_icon ? '' : ' for ' + type.upcase} not found or not readable: #{icon_path || (get_icon_image_path node, type, false)})
904
873
  end
905
874
  end
906
875
  unless icons
@@ -927,7 +896,7 @@ module Asciidoctor
927
896
  advance_page unless first_page
928
897
  rule_segment_height = start_cursor = cursor
929
898
  rule_segment_height -= last_page.cursor if last_page
930
- bounding_box [0, start_cursor], width: label_width + lpad[1], height: rule_segment_height do
899
+ bounding_box [bounds.left, start_cursor], width: label_width + lpad[1], height: rule_segment_height do
931
900
  stroke_vertical_rule rule_color, at: bounds.right, line_style: rule_style, line_width: rule_width
932
901
  end
933
902
  end
@@ -935,7 +904,7 @@ module Asciidoctor
935
904
  end
936
905
  float do
937
906
  adjusted_font_size = nil
938
- bounding_box [0, cursor], width: label_width, height: label_height do
907
+ bounding_box [bounds.left, cursor], width: label_width, height: label_height do
939
908
  if icons == 'font'
940
909
  # FIXME: we assume icon is square
941
910
  icon_size = fit_icon_to_bounds icon_size
@@ -948,14 +917,14 @@ module Asciidoctor
948
917
  end
949
918
  icon icon_data[:name],
950
919
  valign: label_valign,
951
- align: label_align,
920
+ align: label_text_align,
952
921
  color: (icon_data[:stroke_color] || font_color),
953
922
  size: icon_size
954
923
  elsif icons
955
924
  if (::Asciidoctor::Image.format icon_path) == 'svg'
956
925
  begin
957
926
  svg_obj = ::Prawn::SVG::Interface.new (::File.read icon_path, mode: 'r:UTF-8'), self,
958
- position: label_align,
927
+ position: label_text_align,
959
928
  vposition: label_valign,
960
929
  width: label_width,
961
930
  height: label_height,
@@ -981,7 +950,7 @@ module Asciidoctor
981
950
  if (icon_height = icon_width * (1 / icon_aspect_ratio)) > label_height
982
951
  icon_width *= label_height / icon_height
983
952
  end
984
- embed_image image_obj, image_info, width: icon_width, position: label_align, vposition: label_valign
953
+ embed_image image_obj, image_info, width: icon_width, position: label_text_align, vposition: label_valign
985
954
  rescue
986
955
  log :warn, %(could not embed admonition icon image: #{icon_path}; #{$!.message})
987
956
  icons = nil
@@ -1012,7 +981,7 @@ module Asciidoctor
1012
981
  end
1013
982
  @text_transform = nil # already applied to label
1014
983
  ink_prose label_text,
1015
- align: label_align,
984
+ align: label_text_align,
1016
985
  valign: label_valign,
1017
986
  line_height: 1,
1018
987
  margin: 0,
@@ -1035,12 +1004,167 @@ module Asciidoctor
1035
1004
  theme_margin :block, :bottom, (next_enclosed_block node)
1036
1005
  end
1037
1006
 
1007
+ # QUESTION: can we avoid arranging fragments multiple times (conums & autofit) by eagerly preparing arranger?
1008
+ def convert_code node
1009
+ extensions = []
1010
+ source_chunks = bg_color_override = font_color_override = adjusted_font_size = nil
1011
+ theme_font :code do
1012
+ # HACK: disable built-in syntax highlighter; must be done before calling node.content!
1013
+ if node.style == 'source' && (highlighter = (syntax_hl = node.document.syntax_highlighter)&.highlight? && syntax_hl.name)
1014
+ case highlighter
1015
+ when 'coderay'
1016
+ Helpers.require_library CodeRayRequirePath, 'coderay' unless defined? ::Asciidoctor::Prawn::CodeRayEncoder
1017
+ when 'pygments'
1018
+ Helpers.require_library PygmentsRequirePath, 'pygments.rb' unless defined? ::Pygments::Ext::BlockStyles
1019
+ when 'rouge'
1020
+ Helpers.require_library RougeRequirePath, 'rouge' unless defined? ::Rouge::Formatters::Prawn
1021
+ else
1022
+ highlighter = nil
1023
+ end
1024
+ prev_subs = (subs = node.subs).dup
1025
+ callouts_enabled = subs.include? :callouts
1026
+ highlight_idx = subs.index :highlight
1027
+ # NOTE: scratch? here only applies if listing block is nested inside another block
1028
+ if !highlighter || scratch?
1029
+ highlighter = nil
1030
+ if highlight_idx
1031
+ # switch the :highlight sub back to :specialcharacters
1032
+ subs[highlight_idx] = :specialcharacters
1033
+ else
1034
+ prev_subs = nil
1035
+ end
1036
+ source_string = guard_indentation node.content
1037
+ elsif highlight_idx
1038
+ # NOTE: the source highlighter logic below handles the highlight and callouts subs
1039
+ subs.replace subs - [:highlight, :callouts]
1040
+ # NOTE: indentation guards will be added by the source highlighter logic
1041
+ source_string = expand_tabs node.content
1042
+ else
1043
+ highlighter = prev_subs = nil
1044
+ source_string = guard_indentation node.content
1045
+ end
1046
+ else
1047
+ highlighter = nil
1048
+ source_string = guard_indentation node.content
1049
+ end
1050
+
1051
+ case highlighter
1052
+ when 'coderay'
1053
+ source_string, conum_mapping = extract_conums source_string if callouts_enabled
1054
+ srclang = node.attr 'language', 'text'
1055
+ begin
1056
+ ::CodeRay::Scanners[(srclang = (srclang.start_with? 'html+') ? (srclang.slice 5, srclang.length).to_sym : srclang.to_sym)]
1057
+ rescue ::ArgumentError
1058
+ srclang = :text
1059
+ end
1060
+ fragments = (::CodeRay.scan source_string, srclang).to_prawn
1061
+ source_chunks = conum_mapping ? (restore_conums fragments, conum_mapping) : fragments
1062
+ when 'pygments'
1063
+ unless (style = (node.document.attr 'pygments-style')) && (::Pygments::Ext::BlockStyles.available? style)
1064
+ style = 'pastie'
1065
+ end
1066
+ # QUESTION: allow border color to be set by theme for highlighted block?
1067
+ pg_block_styles = ::Pygments::Ext::BlockStyles.for style
1068
+ bg_color_override = pg_block_styles[:background_color]
1069
+ font_color_override = pg_block_styles[:font_color]
1070
+ if source_string.empty?
1071
+ source_chunks = []
1072
+ else
1073
+ lexer = (::Pygments::Lexer.find_by_alias node.attr 'language', 'text') || (::Pygments::Lexer.find_by_mimetype 'text/plain')
1074
+ lexer_opts = { nowrap: true, noclasses: true, stripnl: false, style: style }
1075
+ lexer_opts[:startinline] = !(node.option? 'mixed') if lexer.name == 'PHP'
1076
+ source_string, conum_mapping = extract_conums source_string if callouts_enabled
1077
+ # NOTE: highlight can return nil if something goes wrong; fallback to encoded source string if this happens
1078
+ result = (lexer.highlight source_string, options: lexer_opts) || (node.apply_subs source_string, [:specialcharacters])
1079
+ if node.attr? 'highlight'
1080
+ if (highlight_lines = node.resolve_lines_to_highlight source_string, (node.attr 'highlight')).empty?
1081
+ highlight_lines = nil
1082
+ else
1083
+ pg_highlight_bg_color = pg_block_styles[:highlight_background_color]
1084
+ highlight_lines = highlight_lines.map {|linenum| [linenum, pg_highlight_bg_color] }.to_h
1085
+ end
1086
+ end
1087
+ if (node.option? 'linenums') || (node.attr? 'linenums')
1088
+ linenums = (node.attr 'start', 1).to_i
1089
+ postprocess = true
1090
+ extensions << FormattedText::SourceWrap
1091
+ elsif conum_mapping || highlight_lines
1092
+ postprocess = true
1093
+ end
1094
+ fragments = text_formatter.format result
1095
+ fragments = restore_conums fragments, conum_mapping, linenums, highlight_lines if postprocess
1096
+ source_chunks = guard_indentation_in_fragments fragments
1097
+ end
1098
+ when 'rouge'
1099
+ formatter = (@rouge_formatter ||= ::Rouge::Formatters::Prawn.new theme: (node.document.attr 'rouge-style'), line_gap: @theme.code_line_gap, highlight_background_color: @theme.code_highlight_background_color)
1100
+ # QUESTION: allow border color to be set by theme for highlighted block?
1101
+ bg_color_override = formatter.background_color
1102
+ if source_string.empty?
1103
+ source_chunks = []
1104
+ else
1105
+ if (node.option? 'linenums') || (node.attr? 'linenums')
1106
+ formatter_opts = { line_numbers: true, start_line: (node.attr 'start', 1).to_i }
1107
+ extensions << FormattedText::SourceWrap
1108
+ else
1109
+ formatter_opts = {}
1110
+ end
1111
+ if (srclang = node.attr 'language')
1112
+ if srclang.include? '?'
1113
+ if (lexer = ::Rouge::Lexer.find_fancy srclang) && lexer.tag == 'php' && !(node.option? 'mixed') && !((lexer_opts = lexer.options).key? 'start_inline')
1114
+ lexer = lexer.class.new lexer_opts.merge 'start_inline' => true
1115
+ end
1116
+ elsif (lexer = ::Rouge::Lexer.find srclang)
1117
+ lexer = lexer.new start_inline: true if lexer.tag == 'php' && !(node.option? 'mixed')
1118
+ end
1119
+ end
1120
+ lexer ||= ::Rouge::Lexers::PlainText
1121
+ source_string, conum_mapping = extract_conums source_string if callouts_enabled
1122
+ if (node.attr? 'highlight') && !(hl_lines = (node.resolve_lines_to_highlight source_string, (node.attr 'highlight'))).empty?
1123
+ formatter_opts[:highlight_lines] = hl_lines.map {|linenum| [linenum, true] }.to_h
1124
+ end
1125
+ fragments = formatter.format (lexer.lex source_string), formatter_opts rescue [text: source_string]
1126
+ source_chunks = conum_mapping ? (restore_conums fragments, conum_mapping) : fragments
1127
+ end
1128
+ else
1129
+ # NOTE: only format if we detect a need (callouts or inline formatting)
1130
+ source_chunks = (XMLMarkupRx.match? source_string) ? (text_formatter.format source_string) : [text: source_string]
1131
+ end
1132
+ node.subs.replace prev_subs if prev_subs
1133
+ adjusted_font_size = ((node.option? 'autofit') || (node.document.attr? 'autofit-option')) ? (compute_autofit_font_size source_chunks, :code) : nil
1134
+ end
1135
+
1136
+ caption_below = @theme.code_caption_end&.to_sym == :bottom
1137
+ arrange_block node do |extent|
1138
+ add_dest_for_block node if node.id
1139
+ tare_first_page_content_stream do
1140
+ theme_fill_and_stroke_block :code, extent, background_color: bg_color_override, caption_node: caption_below ? nil : node
1141
+ end
1142
+ pad_box @theme.code_padding, node do
1143
+ theme_font :code do
1144
+ typeset_formatted_text source_chunks, (calc_line_metrics @base_line_height),
1145
+ color: (font_color_override || @theme.code_font_color || @font_color),
1146
+ size: adjusted_font_size,
1147
+ bottom_gutter: @bottom_gutters[-1][node],
1148
+ extensions: extensions.empty? ? nil : extensions
1149
+ end
1150
+ end
1151
+ end
1152
+ # TODO: add protection against the bottom caption being widowed
1153
+ ink_caption node, category: :code, end: :bottom if caption_below
1154
+ theme_margin :block, :bottom, (next_enclosed_block node)
1155
+ end
1156
+
1157
+ alias convert_listing convert_code
1158
+ alias convert_literal convert_code
1159
+ alias convert_listing_or_literal convert_code
1160
+
1038
1161
  def convert_example node
1039
1162
  return convert_open node if node.option? 'collapsible'
1163
+ caption_bottom = @theme.example_caption_end&.to_sym == :bottom
1040
1164
  arrange_block node do |extent|
1041
- add_dest_for_block node if node.id # Q: do we want to put anchor above top margin instead?
1165
+ add_dest_for_block node if node.id
1042
1166
  tare_first_page_content_stream do
1043
- theme_fill_and_stroke_block :example, extent, caption_node: node
1167
+ theme_fill_and_stroke_block :example, extent, caption_node: caption_bottom ? nil : node
1044
1168
  end
1045
1169
  pad_box @theme.example_padding, node do
1046
1170
  theme_font :example do
@@ -1048,6 +1172,8 @@ module Asciidoctor
1048
1172
  end
1049
1173
  end
1050
1174
  end
1175
+ # TODO: add protection against the bottom caption being widowed
1176
+ ink_caption node, category: :example, end: :bottom if caption_bottom
1051
1177
  theme_margin :block, :bottom, (next_enclosed_block node)
1052
1178
  end
1053
1179
 
@@ -1097,7 +1223,7 @@ module Asciidoctor
1097
1223
  advance_page unless first_page
1098
1224
  b_height = start_cursor = cursor
1099
1225
  b_height -= last_page.cursor if last_page
1100
- bounding_box [0, start_cursor], width: bounds.width, height: b_height do
1226
+ bounding_box [bounds.left, start_cursor], width: bounds.width, height: b_height do
1101
1227
  stroke_vertical_rule b_color, line_width: b_left_width, at: b_left_width * 0.5
1102
1228
  end
1103
1229
  end
@@ -1118,7 +1244,7 @@ module Asciidoctor
1118
1244
  end
1119
1245
  end
1120
1246
  if attribution
1121
- margin_bottom @theme.block_margin_bottom
1247
+ theme_margin :block, :bottom
1122
1248
  theme_font %(#{category}_cite) do
1123
1249
  attribution_parts = citetitle ? [attribution, citetitle] : [attribution]
1124
1250
  ink_prose %(#{EmDash} #{attribution_parts.join ', '}), align: :left, normalize: false, margin_bottom: 0
@@ -1152,7 +1278,7 @@ module Asciidoctor
1152
1278
  end
1153
1279
 
1154
1280
  def convert_colist node
1155
- unless at_page_top? || (self_idx = (siblings = node.parent.blocks).index node) == 0 || !([:listing, :literal].include? siblings[self_idx - 1].context)
1281
+ if !at_page_top? && ((prev_context = node.previous_sibling&.context) == :listing || prev_context == :literal)
1156
1282
  margin_top @theme.callout_list_margin_top_after_code
1157
1283
  end
1158
1284
  add_dest_for_block node if node.id
@@ -1160,8 +1286,8 @@ module Asciidoctor
1160
1286
  last_item = node.items[-1]
1161
1287
  item_spacing = @theme.callout_list_item_spacing || @theme.list_item_spacing
1162
1288
  item_opts = { margin_bottom: item_spacing, normalize_line_height: true }
1163
- if (item_align = (resolve_text_align_from_role node.roles) || @theme.list_text_align&.to_sym)
1164
- item_opts[:align] = item_align
1289
+ if (item_text_align = (resolve_text_align_from_role node.roles) || @theme.list_text_align&.to_sym)
1290
+ item_opts[:align] = item_text_align
1165
1291
  end
1166
1292
  theme_font :callout_list do
1167
1293
  line_metrics = theme_font(:conum) { calc_line_metrics @base_line_height }
@@ -1181,7 +1307,7 @@ module Asciidoctor
1181
1307
  theme_font :conum do
1182
1308
  marker_width = rendered_width_of_string %(#{marker = conum_glyph index}x)
1183
1309
  float do
1184
- bounding_box [0, cursor], width: marker_width do
1310
+ bounding_box [bounds.left, cursor], width: marker_width do
1185
1311
  ink_prose marker, align: :center, inline_format: false, margin: 0
1186
1312
  end
1187
1313
  end
@@ -1379,13 +1505,13 @@ module Asciidoctor
1379
1505
  ink_caption node, category: :list, labeled: false if node.title?
1380
1506
 
1381
1507
  opts = {}
1382
- if (align = resolve_text_align_from_role node.roles)
1383
- opts[:align] = align
1508
+ if (text_align = resolve_text_align_from_role node.roles)
1509
+ opts[:align] = text_align
1384
1510
  elsif node.style == 'bibliography'
1385
1511
  opts[:align] = :left
1386
- elsif (align = @theme.list_text_align&.to_sym) # rubocop:disable Lint/DuplicateBranch
1512
+ elsif (text_align = @theme.list_text_align&.to_sym) # rubocop:disable Lint/DuplicateBranch
1387
1513
  # NOTE: theme setting only affects alignment of list text (not nested blocks)
1388
- opts[:align] = align
1514
+ opts[:align] = text_align
1389
1515
  end
1390
1516
 
1391
1517
  line_metrics = calc_line_metrics @base_line_height
@@ -1469,7 +1595,7 @@ module Asciidoctor
1469
1595
  character_spacing_correction = 0.5 if (rendered_width_of_char 'x', character_spacing: -0.5) == marker_gap
1470
1596
  end
1471
1597
  marker_height = height_of_typeset_text marker, line_height: marker_style[:line_height], single_line: true
1472
- start_position = -marker_width + -marker_gap + character_spacing_correction
1598
+ start_position = bounds.left - marker_width - marker_gap + character_spacing_correction
1473
1599
  float do
1474
1600
  advance_page if @media == 'prepress' && cursor < marker_height
1475
1601
  flow_bounding_box position: start_position, width: marker_width do
@@ -1498,30 +1624,18 @@ module Asciidoctor
1498
1624
  traverse_list_item node, list_type, opts
1499
1625
  end
1500
1626
 
1501
- def traverse_list_item node, list_type, opts = {}
1502
- if list_type == :dlist # qanda
1503
- terms, desc = node
1504
- terms.each {|term| ink_prose %(<em>#{term.text}</em>), (opts.merge margin_bottom: @theme.description_list_term_spacing) }
1505
- if desc
1506
- ink_prose desc.text, (opts.merge hyphenate: true) if desc.text?
1507
- traverse desc
1508
- end
1509
- else
1510
- if (primary_text = node.text).nil_or_empty?
1511
- ink_prose DummyText, opts unless node.blocks?
1512
- else
1513
- ink_prose primary_text, (opts.merge hyphenate: true)
1627
+ def convert_image node, opts = {}
1628
+ target, image_format = (node.extend ::Asciidoctor::Image).target_and_format
1629
+
1630
+ unless image_format == 'pdf'
1631
+ if (float_to = node.attr 'float') && ((BlockFloatNames.include? float_to) ? float_to : (float_to = nil))
1632
+ alignment = float_to.to_sym
1633
+ elsif (alignment = node.attr 'align')
1634
+ alignment = (BlockAlignmentNames.include? alignment) ? alignment.to_sym : :left
1635
+ elsif !(alignment = node.roles.reverse.find {|r| BlockAlignmentNames.include? r }&.to_sym)
1636
+ alignment = @theme.image_align&.to_sym || :left
1514
1637
  end
1515
- traverse node
1516
1638
  end
1517
- end
1518
-
1519
- def allocate_space_for_list_item line_metrics
1520
- advance_page if !at_page_top? && cursor < line_metrics.height + line_metrics.leading + line_metrics.padding_top
1521
- end
1522
-
1523
- def convert_image node, opts = {}
1524
- target, image_format = (node.extend ::Asciidoctor::Image).target_and_format
1525
1639
 
1526
1640
  if image_format == 'gif' && !(defined? ::GMagick::Image)
1527
1641
  log :warn, %(GIF image format not supported. Install the prawn-gmagick gem or convert #{target} to PNG.)
@@ -1570,15 +1684,8 @@ module Asciidoctor
1570
1684
  end
1571
1685
  end
1572
1686
 
1573
- return on_image_error :missing, node, target, opts unless image_path
1687
+ return on_image_error :missing, node, target, (opts.merge align: alignment) unless image_path
1574
1688
 
1575
- if (float_to = node.attr 'float') && ((BlockFloatNames.include? float_to) ? float_to : (float_to = nil))
1576
- alignment = float_to.to_sym
1577
- elsif (alignment = node.attr 'align')
1578
- alignment = (BlockAlignmentNames.include? alignment) ? alignment.to_sym : :left
1579
- else
1580
- alignment = (resolve_text_align_from_role node.roles) || @theme.image_align&.to_sym || :left
1581
- end
1582
1689
  # TODO: support cover (aka canvas) image layout using "canvas" (or "cover") role
1583
1690
  width = resolve_explicit_width node.attributes, bounds_width: (available_w = bounds.width), support_vw: true, use_fallback: true, constrain_to_bounds: true
1584
1691
  # TODO: add `to_pt page_width` method to ViewportWidth type
@@ -1635,7 +1742,7 @@ module Asciidoctor
1635
1742
  svg_obj.document.warnings.each do |img_warning|
1636
1743
  log :warn, %(problem encountered in image: #{image_path}; #{img_warning})
1637
1744
  end unless scratch?
1638
- draw_image_border image_cursor, rendered_w, rendered_h, alignment unless node.role? && (node.has_role? 'noborder')
1745
+ draw_image_border image_cursor, rendered_w, rendered_h, alignment unless pinned || (node.role? && (node.has_role? 'noborder'))
1639
1746
  if (link = node.attr 'link')
1640
1747
  add_link_to_image link, { width: rendered_w, height: rendered_h }, position: alignment, y: image_y
1641
1748
  end
@@ -1664,7 +1771,7 @@ module Asciidoctor
1664
1771
  image_cursor = cursor
1665
1772
  # NOTE: specify both width and height to avoid recalculation
1666
1773
  embed_image image_obj, image_info, width: rendered_w, height: rendered_h, position: alignment
1667
- draw_image_border image_cursor, rendered_w, rendered_h, alignment unless node.role? && (node.has_role? 'noborder')
1774
+ draw_image_border image_cursor, rendered_w, rendered_h, alignment unless pinned || (node.role? && (node.has_role? 'noborder'))
1668
1775
  if (link = node.attr 'link')
1669
1776
  add_link_to_image link, { width: rendered_w, height: rendered_h }, position: alignment, y: image_y
1670
1777
  end
@@ -1682,65 +1789,8 @@ module Asciidoctor
1682
1789
  end
1683
1790
  rescue => e
1684
1791
  raise if ::StopIteration === e
1685
- on_image_error :exception, node, target, (opts.merge message: %(could not embed image: #{image_path}; #{e.message}#{::Prawn::Errors::UnsupportedImageType === e && !(defined? ::GMagick::Image) ? '; install prawn-gmagick gem to add support' : ''}))
1686
- end
1687
- end
1688
-
1689
- def supports_float_wrapping? node
1690
- node.context == :paragraph
1691
- end
1692
-
1693
- def init_float_box _node, block_width, block_height, float_to
1694
- gap = ::Array === (gap = @theme.image_float_gap) ? gap.dup : [gap, gap]
1695
- float_w = block_width + (gap[0] ||= 12)
1696
- float_h = block_height + (gap[1] ||= 6)
1697
- box_l = bounds.left + (float_to == 'right' ? 0 : float_w)
1698
- box_t = cursor + block_height
1699
- box_w = bounds.width - float_w
1700
- box_r = box_l + box_w
1701
- box_h = [box_t, float_h].min
1702
- box_b = box_t - box_h
1703
- move_cursor_to box_t
1704
- @float_box = { page: page_number, top: box_t, right: box_r, bottom: box_b, left: box_l, width: box_w, height: box_h, gap: gap }
1705
- end
1706
-
1707
- def draw_image_border top, w, h, alignment
1708
- if (Array @theme.image_border_width).any? {|it| it&.> 0 } && (@theme.image_border_color || @theme.base_border_color)
1709
- if (@theme.image_border_fit || 'content') == 'auto'
1710
- bb_width = bounds.width
1711
- elsif alignment == :center
1712
- bb_x = (bounds.width - w) * 0.5
1713
- elsif alignment == :right
1714
- bb_x = bounds.width - w
1715
- end
1716
- bounding_box [(bb_x || 0), top], width: (bb_width || w), height: h, position: alignment do
1717
- theme_fill_and_stroke_bounds :image
1718
- end
1719
- true
1720
- end
1721
- end
1722
-
1723
- def on_image_error _reason, node, target, opts
1724
- log :warn, opts[:message] if opts.key? :message
1725
- alt_text_vars = { alt: (node.attr 'alt'), target: target }
1726
- alt_text_template = @theme.image_alt_content || '%{link}[%{alt}]%{/link} | <em>%{target}</em>' # rubocop:disable Style/FormatStringToken
1727
- return if alt_text_template.empty?
1728
- if (link = node.attr 'link')
1729
- alt_text_vars[:link] = %(<a href="#{link}">)
1730
- alt_text_vars[:'/link'] = '</a>'
1731
- else
1732
- alt_text_vars[:link] = ''
1733
- alt_text_vars[:'/link'] = ''
1792
+ on_image_error :exception, node, target, (opts.merge align: alignment, message: %(could not embed image: #{image_path}; #{e.message}#{::Prawn::Errors::UnsupportedImageType === e && !(defined? ::GMagick::Image) ? '; install prawn-gmagick gem to add support' : ''}))
1734
1793
  end
1735
- theme_font :image_alt do
1736
- alignment = (alignment = node.attr 'align') ?
1737
- ((BlockAlignmentNames.include? alignment) ? alignment.to_sym : :left) :
1738
- (resolve_text_align_from_role node.roles) || (@theme.image_align&.to_sym || :left)
1739
- ink_prose alt_text_template % alt_text_vars, align: alignment, margin: 0, normalize: false, single_line: true
1740
- end
1741
- ink_caption node, category: :image, end: :bottom if node.title?
1742
- theme_margin :block, :bottom, (next_enclosed_block node) unless opts[:pinned]
1743
- nil
1744
1794
  end
1745
1795
 
1746
1796
  def convert_audio node
@@ -1789,158 +1839,28 @@ module Asciidoctor
1789
1839
  end
1790
1840
  end
1791
1841
 
1792
- # QUESTION: can we avoid arranging fragments multiple times (conums & autofit) by eagerly preparing arranger?
1793
- def convert_code node
1794
- extensions = []
1795
- source_chunks = bg_color_override = font_color_override = adjusted_font_size = nil
1796
- theme_font :code do
1797
- # HACK: disable built-in syntax highlighter; must be done before calling node.content!
1798
- if node.style == 'source' && (highlighter = (syntax_hl = node.document.syntax_highlighter)&.highlight? && syntax_hl.name)
1799
- case highlighter
1800
- when 'coderay'
1801
- Helpers.require_library CodeRayRequirePath, 'coderay' unless defined? ::Asciidoctor::Prawn::CodeRayEncoder
1802
- when 'pygments'
1803
- Helpers.require_library PygmentsRequirePath, 'pygments.rb' unless defined? ::Pygments::Ext::BlockStyles
1804
- when 'rouge'
1805
- Helpers.require_library RougeRequirePath, 'rouge' unless defined? ::Rouge::Formatters::Prawn
1806
- else
1807
- highlighter = nil
1808
- end
1809
- prev_subs = (subs = node.subs).dup
1810
- callouts_enabled = subs.include? :callouts
1811
- highlight_idx = subs.index :highlight
1812
- # NOTE: scratch? here only applies if listing block is nested inside another block
1813
- if !highlighter || scratch?
1814
- highlighter = nil
1815
- if highlight_idx
1816
- # switch the :highlight sub back to :specialcharacters
1817
- subs[highlight_idx] = :specialcharacters
1818
- else
1819
- prev_subs = nil
1820
- end
1821
- source_string = guard_indentation node.content
1822
- elsif highlight_idx
1823
- # NOTE: the source highlighter logic below handles the highlight and callouts subs
1824
- subs.replace subs - [:highlight, :callouts]
1825
- # NOTE: indentation guards will be added by the source highlighter logic
1826
- source_string = expand_tabs node.content
1827
- else
1828
- highlighter = prev_subs = nil
1829
- source_string = guard_indentation node.content
1830
- end
1831
- else
1832
- highlighter = nil
1833
- source_string = guard_indentation node.content
1834
- end
1835
-
1836
- case highlighter
1837
- when 'coderay'
1838
- source_string, conum_mapping = extract_conums source_string if callouts_enabled
1839
- srclang = node.attr 'language', 'text'
1840
- begin
1841
- ::CodeRay::Scanners[(srclang = (srclang.start_with? 'html+') ? (srclang.slice 5, srclang.length).to_sym : srclang.to_sym)]
1842
- rescue ::ArgumentError
1843
- srclang = :text
1844
- end
1845
- fragments = (::CodeRay.scan source_string, srclang).to_prawn
1846
- source_chunks = conum_mapping ? (restore_conums fragments, conum_mapping) : fragments
1847
- when 'pygments'
1848
- unless (style = (node.document.attr 'pygments-style')) && (::Pygments::Ext::BlockStyles.available? style)
1849
- style = 'pastie'
1850
- end
1851
- # QUESTION: allow border color to be set by theme for highlighted block?
1852
- pg_block_styles = ::Pygments::Ext::BlockStyles.for style
1853
- bg_color_override = pg_block_styles[:background_color]
1854
- font_color_override = pg_block_styles[:font_color]
1855
- if source_string.empty?
1856
- source_chunks = []
1857
- else
1858
- lexer = (::Pygments::Lexer.find_by_alias node.attr 'language', 'text') || (::Pygments::Lexer.find_by_mimetype 'text/plain')
1859
- lexer_opts = { nowrap: true, noclasses: true, stripnl: false, style: style }
1860
- lexer_opts[:startinline] = !(node.option? 'mixed') if lexer.name == 'PHP'
1861
- source_string, conum_mapping = extract_conums source_string if callouts_enabled
1862
- # NOTE: highlight can return nil if something goes wrong; fallback to encoded source string if this happens
1863
- result = (lexer.highlight source_string, options: lexer_opts) || (node.apply_subs source_string, [:specialcharacters])
1864
- if node.attr? 'highlight'
1865
- if (highlight_lines = node.resolve_lines_to_highlight source_string, (node.attr 'highlight')).empty?
1866
- highlight_lines = nil
1867
- else
1868
- pg_highlight_bg_color = pg_block_styles[:highlight_background_color]
1869
- highlight_lines = highlight_lines.map {|linenum| [linenum, pg_highlight_bg_color] }.to_h
1870
- end
1871
- end
1872
- if (node.option? 'linenums') || (node.attr? 'linenums')
1873
- linenums = (node.attr 'start', 1).to_i
1874
- postprocess = true
1875
- extensions << FormattedText::SourceWrap
1876
- elsif conum_mapping || highlight_lines
1877
- postprocess = true
1878
- end
1879
- fragments = text_formatter.format result
1880
- fragments = restore_conums fragments, conum_mapping, linenums, highlight_lines if postprocess
1881
- source_chunks = guard_indentation_in_fragments fragments
1882
- end
1883
- when 'rouge'
1884
- formatter = (@rouge_formatter ||= ::Rouge::Formatters::Prawn.new theme: (node.document.attr 'rouge-style'), line_gap: @theme.code_line_gap, highlight_background_color: @theme.code_highlight_background_color)
1885
- # QUESTION: allow border color to be set by theme for highlighted block?
1886
- bg_color_override = formatter.background_color
1887
- if source_string.empty?
1888
- source_chunks = []
1889
- else
1890
- if (node.option? 'linenums') || (node.attr? 'linenums')
1891
- formatter_opts = { line_numbers: true, start_line: (node.attr 'start', 1).to_i }
1892
- extensions << FormattedText::SourceWrap
1893
- else
1894
- formatter_opts = {}
1895
- end
1896
- if (srclang = node.attr 'language')
1897
- if srclang.include? '?'
1898
- if (lexer = ::Rouge::Lexer.find_fancy srclang) && lexer.tag == 'php' && !(node.option? 'mixed') && !((lexer_opts = lexer.options).key? 'start_inline')
1899
- lexer = lexer.class.new lexer_opts.merge 'start_inline' => true
1900
- end
1901
- elsif (lexer = ::Rouge::Lexer.find srclang)
1902
- lexer = lexer.new start_inline: true if lexer.tag == 'php' && !(node.option? 'mixed')
1903
- end
1904
- end
1905
- lexer ||= ::Rouge::Lexers::PlainText
1906
- source_string, conum_mapping = extract_conums source_string if callouts_enabled
1907
- if (node.attr? 'highlight') && !(hl_lines = (node.resolve_lines_to_highlight source_string, (node.attr 'highlight'))).empty?
1908
- formatter_opts[:highlight_lines] = hl_lines.map {|linenum| [linenum, true] }.to_h
1909
- end
1910
- fragments = formatter.format (lexer.lex source_string), formatter_opts rescue [text: source_string]
1911
- source_chunks = conum_mapping ? (restore_conums fragments, conum_mapping) : fragments
1912
- end
1913
- else
1914
- # NOTE: only format if we detect a need (callouts or inline formatting)
1915
- source_chunks = (XMLMarkupRx.match? source_string) ? (text_formatter.format source_string) : [text: source_string]
1842
+ # NOTE: to insert sequential page breaks, you must put {nbsp} between page breaks
1843
+ def convert_page_break node
1844
+ if (page_layout = node.attr 'page-layout').nil_or_empty?
1845
+ unless node.role? && (page_layout = (node.roles.map(&:to_sym) & PageLayouts)[-1])
1846
+ page_layout = nil
1916
1847
  end
1917
- node.subs.replace prev_subs if prev_subs
1918
- adjusted_font_size = ((node.option? 'autofit') || (node.document.attr? 'autofit-option')) ? (compute_autofit_font_size source_chunks, :code) : nil
1848
+ elsif !(PageLayouts.include? (page_layout = page_layout.to_sym))
1849
+ page_layout = nil
1919
1850
  end
1920
1851
 
1921
- arrange_block node do |extent|
1922
- add_dest_for_block node if node.id
1923
- tare_first_page_content_stream do
1924
- theme_fill_and_stroke_block :code, extent, background_color: bg_color_override, caption_node: node
1925
- end
1926
- pad_box @theme.code_padding, node do
1927
- theme_font :code do
1928
- typeset_formatted_text source_chunks, (calc_line_metrics @base_line_height),
1929
- color: (font_color_override || @theme.code_font_color || @font_color),
1930
- size: adjusted_font_size,
1931
- bottom_gutter: @bottom_gutters[-1][node],
1932
- extensions: extensions.empty? ? nil : extensions
1933
- end
1852
+ if at_page_top?
1853
+ if page_layout && page_layout != page.layout && page.empty?
1854
+ delete_current_page
1855
+ advance_page layout: page_layout
1934
1856
  end
1857
+ elsif page_layout
1858
+ advance_page layout: page_layout
1859
+ else
1860
+ advance_page
1935
1861
  end
1936
-
1937
- theme_margin :block, :bottom, (next_enclosed_block node)
1938
1862
  end
1939
1863
 
1940
- alias convert_listing convert_code
1941
- alias convert_literal convert_code
1942
- alias convert_listing_or_literal convert_code
1943
-
1944
1864
  def convert_pass node
1945
1865
  theme_font :code do
1946
1866
  typeset_formatted_text [text: (guard_indentation node.content), color: @theme.base_font_color], (calc_line_metrics @base_line_height)
@@ -1963,100 +1883,14 @@ module Asciidoctor
1963
1883
  theme_margin :block, :bottom, (next_enclosed_block node)
1964
1884
  end
1965
1885
 
1966
- # Extract callout marks from string, indexed by 0-based line number
1967
- # Return an Array with the processed string as the first argument
1968
- # and the mapping of lines to conums as the second.
1969
- def extract_conums string
1970
- conum_mapping = {}
1971
- auto_num = 0
1972
- string = (string.split LF).map.with_index do |line, line_num|
1973
- # FIXME: we get extra spaces before numbers if more than one on a line
1974
- if line.include? '<'
1975
- line = line.gsub CalloutExtractRx do
1976
- # honor the escape
1977
- if $1 == ?\\
1978
- $&.sub $1, ''
1979
- else
1980
- (conum_mapping[line_num] ||= []) << ($3 == '.' ? (auto_num += 1) : $3.to_i)
1981
- ''
1982
- end
1983
- end
1984
- # NOTE: use first position to store space that precedes conums
1985
- if (conum_mapping.key? line_num) && (line.end_with? ' ')
1986
- trimmed_line = line.rstrip
1987
- conum_mapping[line_num].unshift line.slice trimmed_line.length, line.length
1988
- line = trimmed_line
1989
- end
1990
- end
1991
- line
1992
- end.join LF
1993
- conum_mapping = nil if conum_mapping.empty?
1994
- [string, conum_mapping]
1995
- end
1996
-
1997
- # Restore the conums into the Array of formatted text fragments
1998
- #--
1999
- # QUESTION: can this be done more efficiently?
2000
- # QUESTION: can we reuse arrange_fragments_by_line?
2001
- def restore_conums fragments, conum_mapping, linenums = nil, highlight_lines = nil
2002
- lines = []
2003
- line_num = 0
2004
- # reorganize the fragments into an array of lines
2005
- fragments.each do |fragment|
2006
- line = (lines[line_num] ||= [])
2007
- if (text = fragment[:text]) == LF
2008
- lines[line_num += 1] ||= []
2009
- elsif text.include? LF
2010
- (text.split LF, -1).each_with_index do |line_in_fragment, idx|
2011
- line = (lines[line_num += 1] ||= []) unless idx == 0
2012
- line << (fragment.merge text: line_in_fragment) unless line_in_fragment.empty?
2013
- end
2014
- else
2015
- line << fragment
2016
- end
2017
- end
2018
- conum_font_color = @theme.conum_font_color
2019
- if (conum_font_name = @theme.conum_font_family) == font_name
2020
- conum_font_name = nil
2021
- end
2022
- last_line_num = lines.size - 1
2023
- if linenums
2024
- pad_size = (last_line_num + 1).to_s.length
2025
- linenum_color = @theme.code_linenum_font_color
2026
- end
2027
- # append conums to appropriate lines, then flatten to an array of fragments
2028
- lines.flat_map.with_index do |line, cur_line_num|
2029
- last_line = cur_line_num == last_line_num
2030
- visible_line_num = cur_line_num + (linenums || 1)
2031
- if highlight_lines && (highlight_bg_color = highlight_lines[visible_line_num])
2032
- line.unshift text: DummyText, background_color: highlight_bg_color, highlight: true, inline_block: true, extend: true, width: 0, callback: [FormattedText::TextBackgroundAndBorderRenderer]
2033
- end
2034
- line.unshift text: %(#{visible_line_num.to_s.rjust pad_size} ), linenum: visible_line_num, color: linenum_color if linenums
2035
- if conum_mapping && (conums = conum_mapping.delete cur_line_num)
2036
- line << { text: conums.shift } if ::String === conums[0]
2037
- conum_text = conums.map {|num| conum_glyph num }.join ' '
2038
- conum_fragment = { text: conum_text }
2039
- conum_fragment[:color] = conum_font_color if conum_font_color
2040
- conum_fragment[:font] = conum_font_name if conum_font_name
2041
- line << conum_fragment
2042
- end
2043
- line << { text: LF } unless last_line
2044
- line
2045
- end
2046
- end
2047
-
2048
- def conum_glyph number
2049
- @conum_glyphs[number - 1]
2050
- end
2051
-
2052
- def convert_table node
2053
- if !at_page_top? && ((unbreakable = node.option? 'unbreakable') || ((node.option? 'breakable') && (node.id || node.title?)))
2054
- (table_container = Block.new (table_dup = node.dup), :open) << table_dup
2055
- if unbreakable
2056
- table_dup.remove_attr 'unbreakable-option'
2057
- table_container.set_attr 'unbreakable-option'
2058
- else
2059
- table_dup.remove_attr 'breakable-option'
1886
+ def convert_table node
1887
+ if !at_page_top? && ((unbreakable = node.option? 'unbreakable') || ((node.option? 'breakable') && (node.id || node.title?)))
1888
+ (table_container = Block.new (table_dup = node.dup), :open) << table_dup
1889
+ if unbreakable
1890
+ table_dup.remove_attr 'unbreakable-option'
1891
+ table_container.set_attr 'unbreakable-option'
1892
+ else
1893
+ table_dup.remove_attr 'breakable-option'
2060
1894
  end
2061
1895
  table_container.id, table_dup.id = table_dup.id, nil
2062
1896
  if table_dup.title?
@@ -2428,10 +2262,7 @@ module Asciidoctor
2428
2262
  return if @toc_extent
2429
2263
  is_macro = (placement = opts[:placement] || 'macro') == 'macro'
2430
2264
  if ((doc = node.document).attr? 'toc-placement', placement) && (doc.attr? 'toc') && doc.sections?
2431
- if (is_book = doc.doctype == 'book')
2432
- start_new_page unless at_page_top?
2433
- start_new_page if @ppbook && verso_page? && !(is_macro && (node.option? 'nonfacing'))
2434
- end
2265
+ start_toc_page node, placement if (is_book = doc.doctype == 'book')
2435
2266
  add_dest_for_block node, id: (node.id || 'toc') if is_macro
2436
2267
  toc_extent = @toc_extent = allocate_toc doc, (doc.attr 'toclevels', 2).to_i, cursor, (title_page_on = is_book || (doc.attr? 'title-page'))
2437
2268
  @index.start_page_number = toc_extent.to.page + 1 if title_page_on && @theme.page_numbering_start_at == 'after-toc'
@@ -2443,74 +2274,42 @@ module Asciidoctor
2443
2274
  nil
2444
2275
  end
2445
2276
 
2446
- # NOTE: to insert sequential page breaks, you must put {nbsp} between page breaks
2447
- def convert_page_break node
2448
- if (page_layout = node.attr 'page-layout').nil_or_empty?
2449
- unless node.role? && (page_layout = (node.roles.map(&:to_sym) & PageLayouts)[-1])
2450
- page_layout = nil
2451
- end
2452
- elsif !(PageLayouts.include? (page_layout = page_layout.to_sym))
2453
- page_layout = nil
2454
- end
2455
-
2456
- if at_page_top?
2457
- if page_layout && page_layout != page.layout && page.empty?
2458
- delete_current_page
2459
- advance_page layout: page_layout
2460
- end
2461
- elsif page_layout
2462
- advance_page layout: page_layout
2277
+ def traverse node, opts = {}
2278
+ # NOTE: need to reconfigure document to use scratch converter in scratch document
2279
+ if self == (prev_converter = node.document.converter)
2280
+ prev_converter = nil
2463
2281
  else
2464
- advance_page
2282
+ node.document.instance_variable_set :@converter, self
2465
2283
  end
2466
- end
2467
-
2468
- def convert_index_section node
2469
- space_needed_for_category = @theme.description_list_term_spacing + (2 * (height_of_typeset_text 'A'))
2470
- pagenum_sequence_style = node.document.attr 'index-pagenum-sequence-style'
2471
- column_box [0, cursor], columns: @theme.index_columns, width: bounds.width, reflow_margins: true, spacer: @theme.index_column_gap do
2472
- @index.categories.each do |category|
2473
- bounds.move_past_bottom if space_needed_for_category > cursor
2474
- ink_prose category.name,
2475
- align: :left,
2476
- inline_format: false,
2477
- margin_bottom: @theme.description_list_term_spacing,
2478
- style: @theme.description_list_term_font_style&.to_sym
2479
- category.terms.each {|term| convert_index_list_item term, pagenum_sequence_style }
2480
- @theme.prose_margin_bottom > cursor ? bounds.move_past_bottom : (move_down @theme.prose_margin_bottom)
2284
+ if node.blocks?
2285
+ node.content
2286
+ elsif node.content_model != :compound && (string = node.content)
2287
+ prose_opts = opts.merge hyphenate: true, margin_bottom: 0
2288
+ if (bottom_gutter = @bottom_gutters[-1][node])
2289
+ prose_opts[:bottom_gutter] = bottom_gutter
2481
2290
  end
2291
+ ink_prose string, prose_opts
2482
2292
  end
2483
- nil
2293
+ ensure
2294
+ node.document.instance_variable_set :@converter, prev_converter if prev_converter
2484
2295
  end
2485
2296
 
2486
- def convert_index_list_item term, pagenum_sequence_style = nil
2487
- text = escape_xml term.name
2488
- unless term.container?
2489
- if @media == 'screen'
2490
- case pagenum_sequence_style
2491
- when 'page'
2492
- pagenums = term.dests.uniq {|dest| dest[:page] }.map {|dest| %(<a anchor="#{dest[:anchor]}">#{dest[:page]}</a>) }
2493
- when 'range'
2494
- first_anchor_per_page = term.dests.each_with_object({}) {|dest, accum| accum[dest[:page]] ||= dest[:anchor] }
2495
- pagenums = (consolidate_ranges first_anchor_per_page.keys).map do |range|
2496
- anchor = first_anchor_per_page[(range.include? '-') ? (range.partition '-')[0] : range]
2497
- %(<a anchor="#{anchor}">#{range}</a>)
2498
- end
2499
- else # term
2500
- pagenums = term.dests.map {|dest| %(<a anchor="#{dest[:anchor]}">#{dest[:page]}</a>) }
2501
- end
2297
+ def traverse_list_item node, list_type, opts = {}
2298
+ if list_type == :dlist # qanda
2299
+ terms, desc = node
2300
+ terms.each {|term| ink_prose %(<em>#{term.text}</em>), (opts.merge margin_bottom: @theme.description_list_term_spacing) }
2301
+ if desc
2302
+ ink_prose desc.text, (opts.merge hyphenate: true) if desc.text?
2303
+ traverse desc
2304
+ end
2305
+ else
2306
+ if (primary_text = node.text).nil_or_empty?
2307
+ ink_prose DummyText, opts unless node.blocks?
2502
2308
  else
2503
- pagenums = consolidate_ranges term.dests.map {|dest| dest[:page] }.uniq
2309
+ ink_prose primary_text, (opts.merge hyphenate: true)
2504
2310
  end
2505
- text = %(#{text}, #{pagenums.join ', '})
2311
+ traverse node
2506
2312
  end
2507
- subterm_indent = @theme.description_list_description_indent
2508
- ink_prose text, align: :left, margin: 0, hanging_indent: subterm_indent * 2
2509
- indent subterm_indent do
2510
- term.subterms.each do |subterm|
2511
- convert_index_list_item subterm, pagenum_sequence_style
2512
- end
2513
- end unless term.leaf?
2514
2313
  end
2515
2314
 
2516
2315
  def convert_inline_anchor node
@@ -2792,193 +2591,118 @@ module Asciidoctor
2792
2591
  node.id ? %(<a id="#{node.id}">#{DummyText}</a>#{quoted_text}) : quoted_text
2793
2592
  end
2794
2593
 
2795
- # Returns a Boolean indicating whether the title page was created
2796
- def ink_title_page doc
2797
- return unless doc.header? && !doc.notitle && @theme.title_page != false
2594
+ # If an id is provided or the node passed as the first argument has an id,
2595
+ # add a named destination to the document equivalent to the node id at the
2596
+ # current y position. If the node does not have an id and an id is not
2597
+ # specified, do nothing.
2598
+ #
2599
+ # If the node is a section, and the current y position is the top of the
2600
+ # page, set the y position equal to the page height to improve the navigation
2601
+ # experience. If the current x position is at or inside the left margin, set
2602
+ # the x position equal to 0 (left edge of page) to improve the navigation
2603
+ # experience.
2604
+ def add_dest_for_block node, id: nil, y: nil
2605
+ if !scratch? && (id ||= node.id)
2606
+ dest_x = bounds.absolute_left.truncate 4
2607
+ # QUESTION: when content is aligned to left margin, should we keep precise x value or just use 0?
2608
+ dest_x = 0 if dest_x <= page_margin_left
2609
+ unless (dest_y = y)
2610
+ dest_y = @y
2611
+ dest_y += [page_height - dest_y, -@theme.block_anchor_top.to_f].min
2612
+ end
2613
+ # TODO: find a way to store only the ref of the destination; look it up when we need it
2614
+ node.set_attr 'pdf-destination', (node_dest = (dest_xyz dest_x, dest_y))
2615
+ add_dest id, node_dest
2616
+ end
2617
+ nil
2618
+ end
2798
2619
 
2799
- # NOTE: a new page may have already been started at this point, so decide what to do with it
2800
- if page.empty?
2801
- page.reset_content if (recycle = @ppbook ? recto_page? : true)
2802
- elsif @ppbook && page_number > 0 && recto_page?
2803
- start_new_page
2620
+ def add_outline doc, num_levels, toc_page_nums, num_front_matter_pages, has_front_cover
2621
+ if ::String === num_levels
2622
+ if num_levels.include? ':'
2623
+ num_levels, expand_levels = num_levels.split ':', 2
2624
+ num_levels = num_levels.empty? ? (doc.attr 'toclevels', 2).to_i : num_levels.to_i
2625
+ expand_levels = expand_levels.to_i
2626
+ else
2627
+ num_levels = expand_levels = num_levels.to_i
2628
+ end
2629
+ else
2630
+ expand_levels = num_levels
2804
2631
  end
2632
+ front_matter_counter = RomanNumeral.new 0, :lower
2633
+ pagenum_labels = {}
2805
2634
 
2806
- side = page_side (recycle ? nil : page_number + 1), @folio_placement[:inverted]
2807
- prev_bg_image = @page_bg_image[side]
2808
- prev_bg_color = @page_bg_color
2809
- if (bg_image = resolve_background_image doc, @theme, 'title-page-background-image')
2810
- @page_bg_image[side] = bg_image[0] && bg_image
2635
+ num_front_matter_pages.times do |n|
2636
+ pagenum_labels[n] = { P: (::PDF::Core::LiteralString.new front_matter_counter.next!.to_s) }
2811
2637
  end
2812
- if (bg_color = resolve_theme_color :title_page_background_color)
2813
- @page_bg_color = bg_color
2638
+
2639
+ # add labels for each content page, which is required for reader's page navigator to work correctly
2640
+ (num_front_matter_pages..(page_count - 1)).each_with_index do |n, i|
2641
+ pagenum_labels[n] = { P: (::PDF::Core::LiteralString.new (i + 1).to_s) }
2814
2642
  end
2815
- recycle ? float { init_page self } : start_new_page
2816
- @page_bg_image[side] = prev_bg_image if bg_image
2817
- @page_bg_color = prev_bg_color if bg_color
2818
2643
 
2819
- # NOTE: this is the first page created, so we must set the base font
2820
- font @theme.base_font_family, size: @root_font_size, style: @theme.base_font_style
2644
+ unless toc_page_nums.none? || (toc_title = doc.attr 'toc-title').nil_or_empty?
2645
+ toc_section = insert_toc_section doc, toc_title, toc_page_nums
2646
+ end
2821
2647
 
2822
- # QUESTION: allow alignment per element on title page?
2823
- title_align = (@theme.title_page_text_align || @base_text_align).to_sym
2648
+ outline.define do
2649
+ initial_pagenum = has_front_cover ? 2 : 1
2650
+ # FIXME: use sanitize: :plain_text on Document#doctitle once available
2651
+ if document.page_count >= initial_pagenum && (outline_title = doc.attr 'outline-title') &&
2652
+ (outline_title.empty? ? (outline_title = document.resolve_doctitle doc) : outline_title)
2653
+ page title: (document.sanitize outline_title), destination: (document.dest_top initial_pagenum)
2654
+ end
2655
+ # QUESTION: is there any way to get add_outline_level to invoke in the context of the outline?
2656
+ document.add_outline_level self, doc.sections, num_levels, expand_levels
2657
+ end if doc.attr? 'outline'
2824
2658
 
2825
- if @theme.title_page_logo_display != 'none' && (logo_image_path = (doc.attr 'title-logo-image') || (logo_image_from_theme = @theme.title_page_logo_image))
2826
- if (logo_image_path.include? ':') && logo_image_path =~ ImageAttributeValueRx
2827
- logo_image_attrs = (AttributeList.new $2).parse %w(alt width height)
2828
- if logo_image_from_theme
2829
- relative_to_imagesdir = false
2830
- logo_image_path = apply_subs_discretely doc, $1, subs: [:attributes]
2831
- logo_image_path = ThemeLoader.resolve_theme_asset logo_image_path, @themesdir unless doc.is_uri? logo_image_path
2832
- else
2833
- relative_to_imagesdir = true
2834
- logo_image_path = $1
2659
+ toc_section&.remove
2660
+
2661
+ catalog.data[:PageLabels] = state.store.ref Nums: pagenum_labels.flatten
2662
+ primary_page_mode, secondary_page_mode = PageModes[(doc.attr 'pdf-page-mode') || @theme.page_mode]
2663
+ catalog.data[:PageMode] = primary_page_mode
2664
+ catalog.data[:NonFullScreenPageMode] = secondary_page_mode if secondary_page_mode
2665
+ nil
2666
+ end
2667
+
2668
+ def add_outline_level outline, sections, num_levels, expand_levels
2669
+ sections.each do |sect|
2670
+ next if (num_levels_for_sect = (sect.attr 'outlinelevels', num_levels).to_i) < (level = sect.level) ||
2671
+ ((sect.option? 'notitle') && sect == sect.document.last_child && sect.empty?)
2672
+ sect_title = sanitize sect.numbered_title formal: true
2673
+ sect_destination = sect.attr 'pdf-destination'
2674
+ if level < num_levels_for_sect && sect.sections?
2675
+ outline.section sect_title, destination: sect_destination, closed: expand_levels < 1 do
2676
+ add_outline_level outline, sect.sections, num_levels_for_sect, (expand_levels - 1)
2835
2677
  end
2836
2678
  else
2837
- logo_image_attrs = {}
2838
- relative_to_imagesdir = false
2839
- if logo_image_from_theme
2840
- logo_image_path = apply_subs_discretely doc, logo_image_path, subs: [:attributes]
2841
- logo_image_path = ThemeLoader.resolve_theme_asset logo_image_path, @themesdir unless doc.is_uri? logo_image_path
2842
- end
2843
- end
2844
- if (::Asciidoctor::Image.target_and_format logo_image_path)[1] == 'pdf'
2845
- log :error, %(PDF format not supported for title page logo image: #{logo_image_path})
2846
- else
2847
- logo_image_attrs['target'] = logo_image_path
2848
- # NOTE: at the very least, title_align will be a valid alignment value
2849
- logo_image_attrs['align'] = [(logo_image_attrs.delete 'align'), @theme.title_page_logo_align, title_align.to_s].find {|val| (BlockAlignmentNames.include? val) }
2850
- if (logo_image_top = logo_image_attrs['top'] || @theme.title_page_logo_top)
2851
- initial_y, @y = @y, (resolve_top logo_image_top)
2852
- end
2853
- # NOTE: pinned option keeps image on same page
2854
- indent (@theme.title_page_logo_margin_left || 0), (@theme.title_page_logo_margin_right || 0) do
2855
- # FIXME: add API to Asciidoctor for creating blocks outside of extensions
2856
- convert_image (::Asciidoctor::Block.new doc, :image, content_model: :empty, attributes: logo_image_attrs), relative_to_imagesdir: relative_to_imagesdir, pinned: true
2857
- end
2858
- @y = initial_y if initial_y
2859
- end
2860
- end
2861
-
2862
- # TODO: prevent content from spilling to next page
2863
- theme_font :title_page do
2864
- if (title_top = @theme.title_page_title_top)
2865
- @y = resolve_top title_top
2866
- end
2867
- unless @theme.title_page_title_display == 'none'
2868
- doctitle = doc.doctitle partition: true
2869
- move_down @theme.title_page_title_margin_top || 0
2870
- indent (@theme.title_page_title_margin_left || 0), (@theme.title_page_title_margin_right || 0) do
2871
- theme_font :title_page_title do
2872
- ink_prose doctitle.main, align: title_align, margin: 0
2873
- end
2874
- end
2875
- move_down @theme.title_page_title_margin_bottom || 0
2876
- end
2877
- if @theme.title_page_subtitle_display != 'none' && (subtitle = (doctitle || (doc.doctitle partition: true)).subtitle)
2878
- move_down @theme.title_page_subtitle_margin_top || 0
2879
- indent (@theme.title_page_subtitle_margin_left || 0), (@theme.title_page_subtitle_margin_right || 0) do
2880
- theme_font :title_page_subtitle do
2881
- ink_prose subtitle, align: title_align, margin: 0
2882
- end
2883
- end
2884
- move_down @theme.title_page_subtitle_margin_bottom || 0
2885
- end
2886
- if @theme.title_page_authors_display != 'none' && (doc.attr? 'authors')
2887
- move_down @theme.title_page_authors_margin_top || 0
2888
- indent (@theme.title_page_authors_margin_left || 0), (@theme.title_page_authors_margin_right || 0) do
2889
- generic_authors_content = @theme.title_page_authors_content
2890
- authors_content = {
2891
- name_only: @theme.title_page_authors_content_name_only || generic_authors_content,
2892
- with_email: @theme.title_page_authors_content_with_email || generic_authors_content,
2893
- with_url: @theme.title_page_authors_content_with_url || generic_authors_content,
2894
- }
2895
- authors = doc.authors.map.with_index do |author, idx|
2896
- with_author doc, author, idx == 0 do
2897
- author_content_key = (url = doc.attr 'url') ? ((url.start_with? 'mailto:') ? :with_email : :with_url) : :name_only
2898
- if (author_content = authors_content[author_content_key])
2899
- apply_subs_discretely doc, author_content, drop_lines_with_unresolved_attributes: true, imagesdir: @themesdir
2900
- else
2901
- doc.attr 'author'
2902
- end
2903
- end
2904
- end.join @theme.title_page_authors_delimiter
2905
- theme_font :title_page_authors do
2906
- ink_prose authors, align: title_align, margin: 0, normalize: true
2907
- end
2908
- end
2909
- move_down @theme.title_page_authors_margin_bottom || 0
2910
- end
2911
- unless @theme.title_page_revision_display == 'none' || (revision_info = [(doc.attr? 'revnumber') ? %(#{doc.attr 'version-label'} #{doc.attr 'revnumber'}) : nil, (doc.attr 'revdate')].compact).empty?
2912
- move_down @theme.title_page_revision_margin_top || 0
2913
- revision_text = revision_info.join @theme.title_page_revision_delimiter
2914
- if (revremark = doc.attr 'revremark')
2915
- revision_text = %(#{revision_text}: #{revremark})
2916
- end
2917
- indent (@theme.title_page_revision_margin_left || 0), (@theme.title_page_revision_margin_right || 0) do
2918
- theme_font :title_page_revision do
2919
- ink_prose revision_text, align: title_align, margin: 0, normalize: false
2920
- end
2921
- end
2922
- move_down @theme.title_page_revision_margin_bottom || 0
2679
+ outline.page title: sect_title, destination: sect_destination
2923
2680
  end
2924
2681
  end
2925
-
2926
- ink_prose DummyText, margin: 0, line_height: 1, normalize: false if page.empty?
2927
- true
2928
2682
  end
2929
2683
 
2930
- def ink_cover_page doc, face
2931
- image_path, image_opts = resolve_background_image doc, @theme, %(#{face}-cover-image), theme_key: %(cover_#{face}_image).to_sym, symbolic_paths: ['', '~']
2932
- if image_path
2933
- if image_path.empty?
2934
- go_to_page page_count if face == :back
2935
- start_new_page_discretely
2936
- # NOTE: open graphics state to prevent page from being reused
2937
- open_graphics_state if face == :front
2938
- return
2939
- elsif image_path == '~'
2940
- @page_margin_by_side[:cover] = @page_margin_by_side[:recto] if @media == 'prepress'
2941
- return
2942
- end
2943
-
2944
- go_to_page page_count if face == :back
2945
- if image_opts[:format] == 'pdf'
2946
- import_page image_path, (image_opts.merge advance: face != :back, advance_if_missing: false)
2947
- else
2948
- begin
2949
- image_page image_path, image_opts
2950
- rescue
2951
- log :warn, %(could not embed #{face} cover image: #{image_path}; #{$!.message})
2952
- end
2953
- end
2684
+ def apply_subs_discretely doc, value, opts = {}
2685
+ if (imagesdir = opts[:imagesdir])
2686
+ imagesdir_to_restore = doc.attr 'imagesdir'
2687
+ doc.set_attr 'imagesdir', imagesdir
2954
2688
  end
2955
- end
2956
-
2957
- def stamp_foreground_image doc, has_front_cover
2958
- pages = state.pages
2959
- if (first_page = (has_front_cover ? (pages.slice 1, pages.size) : pages).find {|it| !it.imported_page? }) &&
2960
- (first_page_num = (pages.index first_page) + 1) &&
2961
- (fg_image = resolve_background_image doc, @theme, 'page-foreground-image') && fg_image[0]
2962
- go_to_page first_page_num
2963
- create_stamp 'foreground-image' do
2964
- canvas { image fg_image[0], ({ position: :center, vposition: :center }.merge fg_image[1]) }
2965
- end
2966
- stamp 'foreground-image'
2967
- (first_page_num.next..page_count).each do |num|
2968
- go_to_page num
2969
- stamp 'foreground-image' unless page.imported_page?
2689
+ # FIXME: get sub_attributes to handle drop-line w/o a warning
2690
+ doc.set_attr 'attribute-missing', 'skip' unless (attribute_missing = doc.attr 'attribute-missing') == 'skip'
2691
+ value = value.gsub '\{', '\\\\\\{' if (escaped_attr_ref = value.include? '\{')
2692
+ value = (subs = opts[:subs]) ? (doc.apply_subs value, subs) : (doc.apply_subs value)
2693
+ value = (value.split LF).delete_if {|line| SimpleAttributeRefRx.match? line }.join LF if opts[:drop_lines_with_unresolved_attributes] && (value.include? '{')
2694
+ value = value.gsub '\{', '{' if escaped_attr_ref
2695
+ doc.set_attr 'attribute-missing', attribute_missing unless attribute_missing == 'skip'
2696
+ if imagesdir
2697
+ if imagesdir_to_restore
2698
+ doc.set_attr 'imagesdir', imagesdir_to_restore
2699
+ else
2700
+ doc.remove_attr 'imagesdir'
2970
2701
  end
2971
2702
  end
2703
+ value
2972
2704
  end
2973
2705
 
2974
- def start_new_chapter chapter
2975
- start_new_page unless at_page_top?
2976
- # TODO: must call update_colors before advancing to next page if start_new_page is called in ink_chapter_title
2977
- start_new_page if @ppbook && verso_page? && !(chapter.option? 'nonfacing')
2978
- end
2979
-
2980
- alias start_new_part start_new_chapter
2981
-
2982
2706
  # Position the cursor for where to ink the specified section title or discrete heading node.
2983
2707
  #
2984
2708
  # This method computes whether there is enough room on the page to prevent the specified node
@@ -3001,12 +2725,10 @@ module Asciidoctor
3001
2725
  end
3002
2726
  if page == start_page
3003
2727
  page.tare_content_stream
3004
- orphaned = stop_if_first_page_empty do
3005
- node.context == :section ? (traverse node) : (convert (siblings = node.parent.blocks)[(siblings.index node) + 1])
3006
- end
2728
+ orphaned = stop_if_first_page_empty { node.context == :section ? (traverse node) : (convert node.next_sibling) }
3007
2729
  end
3008
2730
  end
3009
- start_new_page if orphaned
2731
+ advance_page if orphaned
3010
2732
  else
3011
2733
  theme_font :heading, level: (hlevel = opts[:level]) do
3012
2734
  h_padding_t, h_padding_r, h_padding_b, h_padding_l = expand_padding_value @theme[%(heading_h#{hlevel}_padding)]
@@ -3015,176 +2737,179 @@ module Asciidoctor
3015
2737
  heading_h = (height_of_typeset_text title) +
3016
2738
  (@theme[%(heading_h#{hlevel}_margin_top)] || @theme.heading_margin_top) +
3017
2739
  (@theme[%(heading_h#{hlevel}_margin_bottom)] || @theme.heading_margin_bottom) + h_padding_t + h_padding_b
3018
- if (min_height_after = @theme.heading_min_height_after) &&
3019
- (node.context == :section ? node.blocks? : node != node.parent.blocks[-1])
2740
+ if (min_height_after = @theme.heading_min_height_after) && (node.context == :section ? node.blocks? : !node.last_child?)
3020
2741
  heading_h += min_height_after
3021
2742
  end
3022
2743
  cursor >= heading_h
3023
2744
  end
3024
- start_new_page unless h_fits
2745
+ advance_page unless h_fits
3025
2746
  end
3026
2747
  end
3027
2748
  nil
3028
2749
  end
3029
2750
 
3030
- def ink_chapter_title node, title, opts = {}
3031
- ink_general_heading node, title, (opts.merge outdent: true)
2751
+ # NOTE: only used when tabsize attribute is not specified
2752
+ # tabs must always be replaced with spaces in order for the indentation guards to work
2753
+ def expand_tabs string
2754
+ if string.nil_or_empty?
2755
+ ''
2756
+ elsif string.include? TAB
2757
+ full_tab_space = ' ' * (tab_size = 4)
2758
+ (string.split LF, -1).map do |line|
2759
+ if line.empty? || !(tab_idx = line.index TAB)
2760
+ line
2761
+ else
2762
+ if tab_idx == 0
2763
+ leading_tabs = 0
2764
+ line.each_byte do |b|
2765
+ break unless b == 9
2766
+ leading_tabs += 1
2767
+ end
2768
+ line = %(#{full_tab_space * leading_tabs}#{rest = line.slice leading_tabs, line.length})
2769
+ next line unless rest.include? TAB
2770
+ end
2771
+ # keeps track of how many spaces were added to adjust offset in match data
2772
+ spaces_added = 0
2773
+ idx = 0
2774
+ result = ''
2775
+ line.each_char do |c|
2776
+ if c == TAB
2777
+ # calculate how many spaces this tab represents, then replace tab with spaces
2778
+ if (offset = idx + spaces_added) % tab_size == 0
2779
+ spaces_added += (tab_size - 1)
2780
+ result += full_tab_space
2781
+ else
2782
+ unless (spaces = tab_size - offset % tab_size) == 1
2783
+ spaces_added += (spaces - 1)
2784
+ end
2785
+ result += (' ' * spaces)
2786
+ end
2787
+ else
2788
+ result += c
2789
+ end
2790
+ idx += 1
2791
+ end
2792
+ result
2793
+ end
2794
+ end.join LF
2795
+ else
2796
+ string
2797
+ end
3032
2798
  end
3033
2799
 
3034
- alias ink_part_title ink_chapter_title
3035
-
3036
- def ink_general_heading _node, title, opts = {}
3037
- ink_heading title, opts
2800
+ # Extract callout marks from string, indexed by 0-based line number
2801
+ # Return an Array with the processed string as the first argument
2802
+ # and the mapping of lines to conums as the second.
2803
+ def extract_conums string
2804
+ conum_mapping = {}
2805
+ auto_num = 0
2806
+ string = (string.split LF).map.with_index do |line, line_num|
2807
+ # FIXME: we get extra spaces before numbers if more than one on a line
2808
+ if line.include? '<'
2809
+ line = line.gsub CalloutExtractRx do
2810
+ # honor the escape
2811
+ if $1 == ?\\
2812
+ $&.sub $1, ''
2813
+ else
2814
+ (conum_mapping[line_num] ||= []) << ($3 == '.' ? (auto_num += 1) : $3.to_i)
2815
+ ''
2816
+ end
2817
+ end
2818
+ # NOTE: use first position to store space that precedes conums
2819
+ if (conum_mapping.key? line_num) && (line.end_with? ' ')
2820
+ trimmed_line = line.rstrip
2821
+ conum_mapping[line_num].unshift line.slice trimmed_line.length, line.length
2822
+ line = trimmed_line
2823
+ end
2824
+ end
2825
+ line
2826
+ end.join LF
2827
+ conum_mapping = nil if conum_mapping.empty?
2828
+ [string, conum_mapping]
3038
2829
  end
3039
2830
 
3040
- # NOTE: ink_heading doesn't set the theme font because it's used for various types of headings
3041
- def ink_heading string, opts = {}
3042
- if (h_level = opts[:level])
3043
- h_category = %(heading_h#{h_level})
3044
- end
3045
- unless (top_margin = (margin = (opts.delete :margin)) || (opts.delete :margin_top))
3046
- if at_page_top?
3047
- if h_category && (top_margin = @theme[%(#{h_category}_margin_page_top)] || @theme.heading_margin_page_top) > 0
3048
- move_down top_margin
2831
+ # Restore the conums into the Array of formatted text fragments
2832
+ #--
2833
+ # QUESTION: can this be done more efficiently?
2834
+ # QUESTION: can we reuse arrange_fragments_by_line?
2835
+ def restore_conums fragments, conum_mapping, linenums = nil, highlight_lines = nil
2836
+ lines = []
2837
+ line_num = 0
2838
+ # reorganize the fragments into an array of lines
2839
+ fragments.each do |fragment|
2840
+ line = (lines[line_num] ||= [])
2841
+ if (text = fragment[:text]) == LF
2842
+ lines[line_num += 1] ||= []
2843
+ elsif text.include? LF
2844
+ (text.split LF, -1).each_with_index do |line_in_fragment, idx|
2845
+ line = (lines[line_num += 1] ||= []) unless idx == 0
2846
+ line << (fragment.merge text: line_in_fragment) unless line_in_fragment.empty?
3049
2847
  end
3050
- top_margin = 0
3051
2848
  else
3052
- top_margin = (h_category ? @theme[%(#{h_category}_margin_top)] : nil) || @theme.heading_margin_top
2849
+ line << fragment
3053
2850
  end
3054
2851
  end
3055
- bot_margin = margin || (opts.delete :margin_bottom) || (h_category ? @theme[%(#{h_category}_margin_bottom)] : nil) || @theme.heading_margin_bottom
3056
- if (transform = resolve_text_transform opts)
3057
- string = transform_text string, transform
2852
+ conum_font_color = @theme.conum_font_color
2853
+ if (conum_font_name = @theme.conum_font_family) == font_name
2854
+ conum_font_name = nil
3058
2855
  end
3059
- outdent_section opts.delete :outdent do
3060
- margin_top top_margin
3061
- start_cursor = cursor
3062
- start_page_number = page_number
3063
- pad_box h_category ? @theme[%(#{h_category}_padding)] : nil do
3064
- # QUESTION: should we move inherited styles to typeset_text?
3065
- if (inherited = apply_text_decoration font_styles, :heading, h_level).empty?
3066
- inline_format_opts = true
3067
- else
3068
- inline_format_opts = [{ inherited: inherited }]
3069
- end
3070
- typeset_text string, (calc_line_metrics (opts.delete :line_height) || @base_line_height), {
3071
- color: @font_color,
3072
- inline_format: inline_format_opts,
3073
- align: @base_text_align.to_sym,
3074
- }.merge(opts)
2856
+ last_line_num = lines.size - 1
2857
+ if linenums
2858
+ pad_size = (last_line_num + 1).to_s.length
2859
+ linenum_color = @theme.code_linenum_font_color
2860
+ end
2861
+ # append conums to appropriate lines, then flatten to an array of fragments
2862
+ lines.flat_map.with_index do |line, cur_line_num|
2863
+ last_line = cur_line_num == last_line_num
2864
+ visible_line_num = cur_line_num + (linenums || 1)
2865
+ if highlight_lines && (highlight_bg_color = highlight_lines[visible_line_num])
2866
+ line.unshift text: DummyText, background_color: highlight_bg_color, highlight: true, inline_block: true, extend: true, width: 0, callback: [FormattedText::TextBackgroundAndBorderRenderer]
3075
2867
  end
3076
- if h_category && @theme[%(#{h_category}_border_width)] &&
3077
- (@theme[%(#{h_category}_border_color)] || @theme.base_border_color) && page_number == start_page_number
3078
- float do
3079
- bounding_box [bounds.left, start_cursor], width: bounds.width, height: start_cursor - cursor do
3080
- theme_fill_and_stroke_bounds h_category
3081
- end
3082
- end
2868
+ line.unshift text: %(#{visible_line_num.to_s.rjust pad_size} ), linenum: visible_line_num, color: linenum_color if linenums
2869
+ if conum_mapping && (conums = conum_mapping.delete cur_line_num)
2870
+ line << { text: conums.shift } if ::String === conums[0]
2871
+ conum_text = conums.map {|num| conum_glyph num }.join ' '
2872
+ conum_fragment = { text: conum_text }
2873
+ conum_fragment[:color] = conum_font_color if conum_font_color
2874
+ conum_fragment[:font] = conum_font_name if conum_font_name
2875
+ line << conum_fragment
3083
2876
  end
3084
- margin_bottom bot_margin
2877
+ line << { text: LF } unless last_line
2878
+ line
3085
2879
  end
3086
2880
  end
3087
2881
 
3088
- # private
3089
- def ink_paragraph_in_float_box node, float_box, prose_opts, role_keys, block_next, insert_margin_bottom
3090
- @float_box = para_font_descender = para_font_size = end_cursor = nil
3091
- if role_keys
3092
- line_metrics = theme_font_cascade role_keys do
3093
- para_font_descender = font.descender
3094
- para_font_size = font_size
3095
- calc_line_metrics @base_line_height
3096
- end
3097
- else
3098
- para_font_descender = font.descender
3099
- para_font_size = font_size
3100
- line_metrics = calc_line_metrics @base_line_height
3101
- end
3102
- # allocate the space of at least one empty line below block
3103
- line_height_length = line_metrics.height + line_metrics.leading + line_metrics.padding_top
3104
- start_page_number = float_box[:page]
3105
- start_cursor = cursor
3106
- block_bottom = (float_box_bottom = float_box[:bottom]) + float_box[:gap][1]
3107
- # use :at to incorporate padding top from line metrics
3108
- # use :final_gap to incorporate padding bottom from line metrics
3109
- # use :draw_text_callback to track end cursor (requires applying :final_gap to result manually)
3110
- prose_opts.update \
3111
- at: [float_box[:left], start_cursor - line_metrics.padding_top],
3112
- width: float_box[:width],
3113
- height: [cursor, float_box[:height] - (float_box[:top] - start_cursor) + line_height_length].min,
3114
- final_gap: para_font_descender + line_metrics.padding_bottom,
3115
- draw_text_callback: (proc do |text, opts|
3116
- draw_text! text, opts
3117
- end_cursor = opts[:at][1] # does not include :final_gap value
3118
- end)
3119
- overflow_text = role_keys ?
3120
- theme_font_cascade(role_keys) { ink_prose node.content, prose_opts } :
3121
- (ink_prose node.content, prose_opts)
3122
- move_cursor_to end_cursor -= prose_opts[:final_gap] if end_cursor # ink_prose with :height does not move cursor
3123
- if overflow_text.empty?
3124
- if block_next && (supports_float_wrapping? block_next)
3125
- insert_margin_bottom.call
3126
- @float_box = float_box if page_number == start_page_number && cursor > start_cursor - prose_opts[:height]
3127
- elsif end_cursor > block_bottom
3128
- move_cursor_to block_bottom
3129
- theme_margin :block, :bottom, block_next
3130
- else
3131
- insert_margin_bottom.call
3132
- end
3133
- else
3134
- overflow_prose_opts = { align: prose_opts[:align] || @base_text_align.to_sym }
3135
- unless end_cursor
3136
- overflow_prose_opts[:indent_paragraphs] = prose_opts[:indent_paragraphs]
3137
- move_cursor_to float_box_bottom if start_cursor > float_box_bottom
3138
- end
3139
- role_keys ?
3140
- theme_font_cascade(role_keys) { typeset_formatted_text overflow_text, line_metrics, overflow_prose_opts } :
3141
- (typeset_formatted_text overflow_text, line_metrics, overflow_prose_opts)
3142
- insert_margin_bottom.call
3143
- end
2882
+ def fallback_svg_font_name
2883
+ @theme.svg_fallback_font_family || @theme.svg_font_family || @theme.base_font_family
3144
2884
  end
3145
2885
 
3146
- # NOTE: inline_format is true by default
3147
- def ink_prose string, opts = {}
3148
- top_margin = (margin = (opts.delete :margin)) || (opts.delete :margin_top) || 0
3149
- bot_margin = margin || (opts.delete :margin_bottom) || @theme.prose_margin_bottom
3150
- if (transform = resolve_text_transform opts)
3151
- string = transform_text string, transform
3152
- end
3153
- string = hyphenate_text string, @hyphenator if (opts.delete :hyphenate) && (defined? @hyphenator)
3154
- # NOTE: used by extensions; ensures linked text gets formatted using the link styles
3155
- if (anchor = opts.delete :anchor)
3156
- string = anchor == true ? %(<a>#{string}</a>) : %(<a anchor="#{anchor}">#{string}</a>)
3157
- end
3158
- margin_top top_margin
3159
- # NOTE: normalize makes endlines soft (replaces "\n" with ' ')
3160
- inline_format_opts = { normalize: (opts.delete :normalize) != false }
3161
- if (styles = opts.delete :styles)
3162
- inline_format_opts[:inherited] = {
3163
- styles: styles,
3164
- text_decoration_color: (opts.delete :text_decoration_color),
3165
- text_decoration_width: (opts.delete :text_decoration_width),
3166
- }.compact
2886
+ # Add an indentation guard at the start of indented lines.
2887
+ # Expand tabs to spaces if tabs are present
2888
+ def guard_indentation string
2889
+ unless (string = expand_tabs string).empty?
2890
+ string[0] = GuardedIndent if string.start_with? ' '
2891
+ string.gsub! InnerIndent, GuardedInnerIndent if string.include? InnerIndent
3167
2892
  end
3168
- result = typeset_text string, (calc_line_metrics (opts.delete :line_height) || @base_line_height), {
3169
- color: @font_color,
3170
- inline_format: [inline_format_opts],
3171
- align: @base_text_align.to_sym,
3172
- }.merge(opts)
3173
- margin_bottom bot_margin
3174
- result
2893
+ string
3175
2894
  end
3176
2895
 
3177
- def generate_manname_section node
3178
- title = node.attr 'manname-title', 'Name'
3179
- if (next_section_title = node.sections[0]&.title) && next_section_title.upcase == next_section_title
3180
- title = title.upcase
2896
+ def guard_indentation_in_fragments fragments
2897
+ start_of_line = true
2898
+ fragments.each do |fragment|
2899
+ next if (text = fragment[:text]).empty?
2900
+ if start_of_line && (text.start_with? ' ')
2901
+ fragment[:text] = GuardedIndent + (((text = text.slice 1, text.length).include? InnerIndent) ? (text.gsub InnerIndent, GuardedInnerIndent) : text)
2902
+ elsif text.include? InnerIndent
2903
+ fragment[:text] = text.gsub InnerIndent, GuardedInnerIndent
2904
+ end
2905
+ start_of_line = text.end_with? LF
3181
2906
  end
3182
- sect = Section.new node, 1
3183
- sect.sectname = 'section'
3184
- sect.id = node.attr 'manname-id'
3185
- sect.title = title
3186
- sect << (Block.new sect, :paragraph, source: %(#{node.attr 'manname'} - #{node.attr 'manpurpose'}), subs: :normal)
3187
- sect
2907
+ fragments
2908
+ end
2909
+
2910
+ def height_of_typeset_text string, opts = {}
2911
+ line_metrics = (calc_line_metrics opts[:line_height] || @base_line_height)
2912
+ (height_of string, leading: line_metrics.leading, final_gap: line_metrics.final_gap) + line_metrics.padding_top + (opts[:single_line] ? 0 : line_metrics.padding_bottom)
3188
2913
  end
3189
2914
 
3190
2915
  # Render the caption in the current document. If the dry_run option is true, return the height.
@@ -3277,7 +3002,7 @@ module Asciidoctor
3277
3002
  end
3278
3003
  unless scratch? || !(bg_color = @theme[%(#{category_caption}_background_color)] || @theme.caption_background_color)
3279
3004
  caption_height = height_of_typeset_text string
3280
- fill_at = [0, cursor + (margin[:top] || 0)]
3005
+ fill_at = [bounds.left, cursor + (margin[:top] || 0)]
3281
3006
  float { bounding_box(fill_at, width: container_width, height: caption_height) { fill_bounds bg_color } }
3282
3007
  end
3283
3008
  indent(*indent_by) do
@@ -3299,389 +3024,148 @@ module Asciidoctor
3299
3024
  ink_caption node, category: :table, end: end_, block_align: table_alignment, block_width: table_width, max_width: max_width
3300
3025
  end
3301
3026
 
3302
- def allocate_toc doc, toc_num_levels, toc_start_cursor, title_page_on
3303
- toc_start_page_number = page_number
3304
- to_page = nil
3305
- extent = dry_run onto: self do
3306
- to_page = (ink_toc doc, toc_num_levels, toc_start_page_number, toc_start_cursor).end
3307
- margin_bottom @theme.block_margin_bottom unless title_page_on
3308
- end
3309
- # NOTE: patch for custom converters that allocate extra TOC pages without actually creating them
3310
- if to_page > extent.to.page
3311
- extent.to.page = to_page
3312
- extent.to.cursor = bounds.height
3313
- end
3314
- # NOTE: reserve pages for the toc; leaves cursor on page after last page in toc
3315
- if title_page_on
3316
- extent.each_page { start_new_page }
3317
- else
3318
- extent.each_page {|first_page| start_new_page unless first_page }
3319
- move_cursor_to extent.to.cursor
3320
- end
3321
- extent
3027
+ def ink_chapter_title node, title, opts = {}
3028
+ ink_general_heading node, title, (opts.merge outdent: true)
3322
3029
  end
3323
3030
 
3324
- def get_entries_for_toc node
3325
- node.sections
3326
- end
3031
+ alias ink_part_title ink_chapter_title
3327
3032
 
3328
- # NOTE: num_front_matter_pages not used during a dry run
3329
- def ink_toc doc, num_levels, toc_page_number, start_cursor, num_front_matter_pages = 0
3330
- go_to_page toc_page_number unless (page_number == toc_page_number) || scratch?
3331
- start_page_number = page_number
3332
- move_cursor_to start_cursor
3333
- unless (toc_title = doc.attr 'toc-title').nil_or_empty?
3334
- theme_font_cascade [[:heading, level: 2], :toc_title] do
3335
- toc_title_align = (@theme.toc_title_text_align || @theme.heading_h2_text_align || @theme.heading_text_align || @base_text_align).to_sym
3336
- ink_general_heading doc, toc_title, align: toc_title_align, level: 2, outdent: true, role: :toctitle
3033
+ def ink_cover_page doc, face
3034
+ image_path, image_opts = resolve_background_image doc, @theme, %(#{face}-cover-image), theme_key: %(cover_#{face}_image).to_sym, symbolic_paths: ['', '~']
3035
+ if image_path
3036
+ if image_path.empty?
3037
+ go_to_page page_count if face == :back
3038
+ start_new_page_discretely
3039
+ # NOTE: open graphics state to prevent page from being reused
3040
+ open_graphics_state if face == :front
3041
+ return
3042
+ elsif image_path == '~'
3043
+ @page_margin_by_side[:cover] = @page_margin_by_side[:recto] if @media == 'prepress'
3044
+ return
3337
3045
  end
3338
- end
3339
- # QUESTION: should we skip this whole method if num_levels < 0?
3340
- unless num_levels < 0
3341
- dot_leader = theme_font :toc do
3342
- # TODO: we could simplify by using nested theme_font :toc_dot_leader
3343
- if (dot_leader_font_style = @theme.toc_dot_leader_font_style&.to_sym || :normal) != font_style
3344
- font_style dot_leader_font_style
3046
+
3047
+ go_to_page page_count if face == :back
3048
+ if image_opts[:format] == 'pdf'
3049
+ import_page image_path, (image_opts.merge advance: face != :back, advance_if_missing: false)
3050
+ else
3051
+ begin
3052
+ image_page image_path, image_opts
3053
+ rescue
3054
+ log :warn, %(could not embed #{face} cover image: #{image_path}; #{$!.message})
3345
3055
  end
3346
- font_size @theme.toc_dot_leader_font_size
3347
- {
3348
- font_color: @theme.toc_dot_leader_font_color || @font_color,
3349
- font_style: dot_leader_font_style,
3350
- font_size: font_size,
3351
- levels: ((dot_leader_l = @theme.toc_dot_leader_levels) == 'none' ? ::Set.new :
3352
- (dot_leader_l && dot_leader_l != 'all' ? dot_leader_l.to_s.split.map(&:to_i).to_set : (0..num_levels).to_set)),
3353
- text: (dot_leader_text = @theme.toc_dot_leader_content || DotLeaderTextDefault),
3354
- width: dot_leader_text.empty? ? 0 : (rendered_width_of_string dot_leader_text),
3355
- # TODO: spacer gives a little bit of room between dots and page number
3356
- spacer: { text: NoBreakSpace, size: (spacer_font_size = @font_size * 0.25) },
3357
- spacer_width: (rendered_width_of_char NoBreakSpace, size: spacer_font_size),
3358
- }
3359
3056
  end
3360
- theme_margin :toc, :top
3361
- ink_toc_level (get_entries_for_toc doc), num_levels, dot_leader, num_front_matter_pages
3362
3057
  end
3363
- # NOTE: range must be calculated relative to toc_page_number; absolute page number in scratch document is arbitrary
3364
- toc_page_numbers = (toc_page_number..(toc_page_number + (page_number - start_page_number)))
3365
- go_to_page page_count unless scratch?
3366
- toc_page_numbers
3367
3058
  end
3368
3059
 
3369
- def ink_toc_level entries, num_levels, dot_leader, num_front_matter_pages
3370
- # NOTE: font options aren't always reliable, so store size separately
3371
- toc_font_info = theme_font :toc do
3372
- { font: font, size: @font_size }
3373
- end
3374
- hanging_indent = @theme.toc_hanging_indent
3375
- entries.each do |entry|
3376
- next if (num_levels_for_entry = (entry.attr 'toclevels', num_levels).to_i) < (entry_level = entry.level + 1).pred
3377
- theme_font :toc, level: entry_level do
3378
- next unless (entry_anchor = (entry.attr 'pdf-anchor') || entry.id)
3379
- entry_title = entry.context == :section ? entry.numbered_title : (entry.title? ? entry.title : (entry.xreftext 'basic'))
3380
- next if entry_title.empty?
3381
- entry_title = transform_text entry_title, @text_transform if @text_transform
3382
- pgnum_label_placeholder_width = rendered_width_of_string '0' * @toc_max_pagenum_digits
3383
- # NOTE: only write title (excluding dots and page number) if this is a dry run
3384
- if scratch?
3385
- indent 0, pgnum_label_placeholder_width do
3386
- # NOTE: must wrap title in empty anchor element in case links are styled with different font family / size
3387
- ink_prose entry_title, anchor: true, normalize: false, hanging_indent: hanging_indent, normalize_line_height: true, margin: 0
3388
- end
3389
- else
3390
- if !(physical_pgnum = entry.attr 'pdf-page-start') &&
3391
- (target_page_ref = (get_dest entry_anchor)&.first) &&
3392
- (target_page_idx = state.pages.index {|candidate| candidate.dictionary == target_page_ref })
3393
- physical_pgnum = target_page_idx + 1
3394
- end
3395
- if physical_pgnum
3396
- virtual_pgnum = physical_pgnum - num_front_matter_pages
3397
- pgnum_label = (virtual_pgnum < 1 ? (RomanNumeral.new physical_pgnum, :lower) : virtual_pgnum).to_s
3398
- else
3399
- pgnum_label = '?'
3400
- end
3401
- start_page_number = page_number
3402
- start_cursor = cursor
3403
- start_dots = nil
3404
- entry_title_inherited = (apply_text_decoration ::Set.new, :toc, entry_level).merge anchor: entry_anchor, color: @font_color
3405
- # NOTE: use text formatter to add anchor overlay to avoid using inline format with synthetic anchor tag
3406
- entry_title_fragments = text_formatter.format entry_title, inherited: entry_title_inherited
3407
- line_metrics = calc_line_metrics @base_line_height
3408
- indent 0, pgnum_label_placeholder_width do
3409
- (entry_title_fragments[-1][:callback] ||= []) << (last_fragment_pos = ::Asciidoctor::PDF::FormattedText::FragmentPositionRenderer.new)
3410
- typeset_formatted_text entry_title_fragments, line_metrics, hanging_indent: hanging_indent, normalize_line_height: true
3411
- start_dots = last_fragment_pos.right + hanging_indent
3412
- last_fragment_cursor = last_fragment_pos.top + line_metrics.padding_top
3413
- start_cursor = last_fragment_cursor if last_fragment_pos.page_number > start_page_number || (start_cursor - last_fragment_cursor) > line_metrics.height
3414
- end
3415
- end_cursor = cursor
3416
- move_cursor_to start_cursor
3417
- # NOTE: we're guaranteed to be on the same page as the final line of the entry
3418
- if dot_leader[:width] > 0 && (dot_leader[:levels].include? entry_level.pred)
3419
- pgnum_label_width = rendered_width_of_string pgnum_label
3420
- pgnum_label_font_settings = { color: @font_color, font: font_family, size: @font_size, styles: font_styles }
3421
- save_font do
3422
- # NOTE: the same font is used for dot leaders throughout toc
3423
- set_font toc_font_info[:font], dot_leader[:font_size]
3424
- font_style dot_leader[:font_style]
3425
- num_dots = [((bounds.width - start_dots - dot_leader[:spacer_width] - pgnum_label_width) / dot_leader[:width]).floor, 0].max
3426
- # FIXME: dots don't line up in columns if width of page numbers differ
3427
- typeset_formatted_text [
3428
- { text: dot_leader[:text] * num_dots, color: dot_leader[:font_color] },
3429
- dot_leader[:spacer],
3430
- ({ text: pgnum_label, anchor: entry_anchor }.merge pgnum_label_font_settings),
3431
- ], line_metrics, align: :right
3432
- end
3433
- else
3434
- typeset_formatted_text [{ text: pgnum_label, color: @font_color, anchor: entry_anchor }], line_metrics, align: :right
3060
+ # QUESTION: if a footnote ref appears in a separate chapter, should the footnote def be duplicated?
3061
+ def ink_footnotes node
3062
+ return if (fns = (doc = node.document).footnotes - @rendered_footnotes).empty?
3063
+ theme_margin :block, :bottom if node.context == :document || node == node.document.last_child
3064
+ theme_margin :footnotes, :top
3065
+ with_dry_run do |extent|
3066
+ if (single_page_height = extent&.single_page_height) && (delta = cursor - single_page_height - 0.0001) > 0
3067
+ move_down delta
3068
+ end
3069
+ theme_font :footnotes do
3070
+ (title = doc.attr 'footnotes-title') && (ink_caption title, category: :footnotes)
3071
+ item_spacing = @theme.footnotes_item_spacing
3072
+ index_offset = @rendered_footnotes.length
3073
+ sect_xreftext = node.context == :section && (node.xreftext node.document.attr 'xrefstyle')
3074
+ fns.each do |fn|
3075
+ label = (index = fn.index) - index_offset
3076
+ if sect_xreftext
3077
+ fn.singleton_class.send :attr_accessor, :label unless fn.respond_to? :label=
3078
+ fn.label = %(#{label} - #{sect_xreftext})
3435
3079
  end
3436
- move_cursor_to end_cursor
3080
+ ink_prose %(<a id="_footnotedef_#{index}">#{DummyText}</a>[<a anchor="_footnoteref_#{index}">#{label}</a>] #{fn.text}), margin_bottom: item_spacing, hyphenate: true
3437
3081
  end
3082
+ @rendered_footnotes += fns if extent
3438
3083
  end
3439
- indent @theme.toc_indent do
3440
- ink_toc_level (get_entries_for_toc entry), num_levels_for_entry, dot_leader, num_front_matter_pages
3441
- end if num_levels_for_entry >= entry_level
3442
3084
  end
3085
+ nil
3443
3086
  end
3444
3087
 
3445
- # Reduce icon height to fit inside bounds.height. Icons will not render
3446
- # properly if they are larger than the current bounds.height.
3447
- def fit_icon_to_bounds preferred_size
3448
- (max_height = bounds.height) < preferred_size ? max_height : preferred_size
3088
+ def ink_general_heading _node, title, opts = {}
3089
+ ink_heading title, opts
3449
3090
  end
3450
3091
 
3451
- def admonition_icon_data key
3452
- if (icon_data = @theme[%(admonition_icon_#{key})])
3453
- icon_data = (AdmonitionIcons[key] || {}).merge icon_data
3454
- if (icon_name = icon_data[:name])
3455
- unless icon_name.start_with?(*IconSetPrefixes)
3456
- log(:info) { %(#{key} admonition in theme uses icon from deprecated fa icon set; use fas, far, or fab instead) }
3457
- icon_data[:name] = %(fa-#{icon_name}) unless icon_name.start_with? 'fa-'
3092
+ # NOTE: ink_heading doesn't set the theme font because it's used for various types of headings
3093
+ def ink_heading string, opts = {}
3094
+ if (h_level = opts[:level])
3095
+ h_category = %(heading_h#{h_level})
3096
+ end
3097
+ unless (top_margin = (margin = (opts.delete :margin)) || (opts.delete :margin_top))
3098
+ if at_page_top?
3099
+ if h_category && (top_margin = @theme[%(#{h_category}_margin_page_top)] || @theme.heading_margin_page_top) > 0
3100
+ move_down top_margin
3458
3101
  end
3102
+ top_margin = 0
3459
3103
  else
3460
- icon_data[:name] = AdmonitionIcons[:note][:name]
3104
+ top_margin = (h_category ? @theme[%(#{h_category}_margin_top)] : nil) || @theme.heading_margin_top
3461
3105
  end
3462
- else
3463
- (icon_data = AdmonitionIcons[key] || {})[:name] ||= AdmonitionIcons[:note][:name]
3464
3106
  end
3465
- icon_data
3466
- end
3467
-
3468
- # TODO: delegate to ink_page_header and ink_page_footer per page
3469
- def ink_running_content periphery, doc, skip = [1, 1], body_start_page_number = 1
3470
- skip_pages, skip_pagenums = skip
3471
- # NOTE: find and advance to first non-imported content page to use as model page
3472
- return unless (content_start_page_number = state.pages[skip_pages..-1].index {|it| !it.imported_page? })
3473
- content_start_page_number += (skip_pages + 1)
3474
- num_pages = page_count
3475
- prev_page_number = page_number
3476
- go_to_page content_start_page_number
3477
-
3478
- # FIXME: probably need to treat doctypes differently
3479
- is_book = doc.doctype == 'book'
3480
- header = doc.header? ? doc.header : nil
3481
- sectlevels = (@theme[%(#{periphery}_sectlevels)] || 2).to_i
3482
- sections = doc.find_by(context: :section) {|sect| sect.level <= sectlevels && sect != header }
3483
- toc_title = (doc.attr 'toc-title').to_s if (toc_page_nums = @toc_extent&.page_range)
3484
- disable_on_pages = @disable_running_content[periphery]
3485
-
3486
- title_method = TitleStyles[@theme[%(#{periphery}_title_style)]]
3487
- # FIXME: we need a proper model for all this page counting
3488
- # FIXME: we make a big assumption that part & chapter start on new pages
3489
- # index parts, chapters and sections by the physical page number on which they start
3490
- part_start_pages = {}
3491
- chapter_start_pages = {}
3492
- section_start_pages = {}
3493
- trailing_section_start_pages = {}
3494
- sections.each do |sect|
3495
- pgnum = (sect.attr 'pdf-page-start').to_i
3496
- if is_book && ((sect_is_part = sect.sectname == 'part') || sect.level == 1)
3497
- if sect_is_part
3498
- part_start_pages[pgnum] ||= sect
3107
+ bot_margin = margin || (opts.delete :margin_bottom) || (h_category ? @theme[%(#{h_category}_margin_bottom)] : nil) || @theme.heading_margin_bottom
3108
+ if (transform = resolve_text_transform opts)
3109
+ string = transform_text string, transform
3110
+ end
3111
+ outdent_section opts.delete :outdent do
3112
+ margin_top top_margin
3113
+ start_cursor = cursor
3114
+ start_page_number = page_number
3115
+ pad_box h_category ? @theme[%(#{h_category}_padding)] : nil do
3116
+ # QUESTION: should we move inherited styles to typeset_text?
3117
+ if (inherited = apply_text_decoration font_styles, :heading, h_level).empty?
3118
+ inline_format_opts = true
3499
3119
  else
3500
- chapter_start_pages[pgnum] ||= sect
3501
- # FIXME: need a better way to indicate that part has ended
3502
- part_start_pages[pgnum] = '' if sect.sectname == 'appendix' && !part_start_pages.empty?
3120
+ inline_format_opts = [{ inherited: inherited }]
3503
3121
  end
3504
- else
3505
- trailing_section_start_pages[pgnum] = sect
3506
- section_start_pages[pgnum] ||= sect
3507
- end
3508
- end
3509
-
3510
- # index parts, chapters, and sections by the physical page number on which they appear
3511
- parts_by_page = SectionInfoByPage.new title_method
3512
- chapters_by_page = SectionInfoByPage.new title_method
3513
- sections_by_page = SectionInfoByPage.new title_method
3514
- # QUESTION: should the default part be the doctitle?
3515
- last_part = nil
3516
- # QUESTION: should we enforce that the preamble is a preface?
3517
- last_chap = is_book ? :pre : nil
3518
- last_sect = nil
3519
- sect_search_threshold = 1
3520
- (1..num_pages).each do |pgnum|
3521
- if (part = part_start_pages[pgnum])
3522
- last_part = part
3523
- last_chap = nil
3524
- last_sect = nil
3525
- end
3526
- if (chap = chapter_start_pages[pgnum])
3527
- last_chap = chap
3528
- last_sect = nil
3122
+ typeset_text string, (calc_line_metrics (opts.delete :line_height) || @base_line_height), {
3123
+ color: @font_color,
3124
+ inline_format: inline_format_opts,
3125
+ align: @base_text_align.to_sym,
3126
+ }.merge(opts)
3529
3127
  end
3530
- if (sect = section_start_pages[pgnum])
3531
- last_sect = sect
3532
- elsif part || chap
3533
- sect_search_threshold = pgnum
3534
- # NOTE: we didn't find a section on this page; look back to find last section started
3535
- elsif last_sect
3536
- (sect_search_threshold..(pgnum - 1)).reverse_each do |prev|
3537
- if (sect = trailing_section_start_pages[prev])
3538
- last_sect = sect
3539
- break
3128
+ if h_category && @theme[%(#{h_category}_border_width)] &&
3129
+ (@theme[%(#{h_category}_border_color)] || @theme.base_border_color) && page_number == start_page_number
3130
+ float do
3131
+ bounding_box [bounds.left, start_cursor], width: bounds.width, height: start_cursor - cursor do
3132
+ theme_fill_and_stroke_bounds h_category
3540
3133
  end
3541
3134
  end
3542
3135
  end
3543
- parts_by_page[pgnum] = last_part
3544
- if toc_page_nums&.cover? pgnum
3545
- if is_book
3546
- chapters_by_page[pgnum] = toc_title
3547
- sections_by_page[pgnum] = nil
3548
- else
3549
- chapters_by_page[pgnum] = nil
3550
- sections_by_page[pgnum] = section_start_pages[pgnum] || toc_title
3551
- end
3552
- toc_page_nums = nil if toc_page_nums.end == pgnum
3553
- elsif last_chap == :pre
3554
- chapters_by_page[pgnum] = pgnum < body_start_page_number ? doc.doctitle : (doc.attr 'preface-title', 'Preface')
3555
- sections_by_page[pgnum] = last_sect
3556
- else
3557
- chapters_by_page[pgnum] = last_chap
3558
- sections_by_page[pgnum] = last_sect
3559
- end
3136
+ margin_bottom bot_margin
3560
3137
  end
3138
+ end
3561
3139
 
3562
- doctitle = resolve_doctitle doc, true
3563
- # NOTE: set doctitle again so it's properly escaped
3564
- doc.set_attr 'doctitle', doctitle.combined
3565
- doc.set_attr 'document-title', doctitle.main
3566
- doc.set_attr 'document-subtitle', doctitle.subtitle
3567
- doc.set_attr 'page-count', (num_pages - skip_pagenums)
3568
-
3569
- pagenums_enabled = doc.attr? 'pagenums'
3570
- periphery_layout_cache = {}
3571
- # NOTE: this block is invoked during PDF generation, after #write -> #render_file and thus after #convert_document
3572
- repeat (content_start_page_number..num_pages), dynamic: true do
3573
- pgnum = page_number
3574
- # NOTE: don't write on pages which are imported / inserts (otherwise we can get a corrupt PDF)
3575
- next if page.imported_page? || (disable_on_pages.include? pgnum)
3576
- virtual_pgnum = pgnum - skip_pagenums
3577
- pgnum_label = (virtual_pgnum < 1 ? (RomanNumeral.new pgnum, :lower) : virtual_pgnum).to_s
3578
- side = page_side((@folio_placement[:basis] == :physical ? pgnum : virtual_pgnum), @folio_placement[:inverted])
3579
- doc.set_attr 'page-layout', page.layout.to_s
3580
-
3581
- # NOTE: running content is cached per page layout
3582
- # QUESTION: should allocation be per side?
3583
- trim_styles, colspec_dict, content_dict, stamp_names = allocate_running_content_layout doc, page, periphery, periphery_layout_cache
3584
- # FIXME: we need to have a content setting for chapter pages
3585
- content_by_position, colspec_by_position = content_dict[side], colspec_dict[side]
3586
-
3587
- doc.set_attr 'page-number', pgnum_label if pagenums_enabled
3588
- # QUESTION: should the fallback value be nil instead of empty string? or should we remove attribute if no value?
3589
- doc.set_attr 'part-title', ((part_info = parts_by_page[pgnum])[:title] || '')
3590
- if (part_numeral = part_info[:numeral])
3591
- doc.set_attr 'part-numeral', part_numeral
3592
- else
3593
- doc.remove_attr 'part-numeral'
3594
- end
3595
- doc.set_attr 'chapter-title', ((chap_info = chapters_by_page[pgnum])[:title] || '')
3596
- if (chap_numeral = chap_info[:numeral])
3597
- doc.set_attr 'chapter-numeral', chap_numeral
3598
- else
3599
- doc.remove_attr 'chapter-numeral'
3600
- end
3601
- doc.set_attr 'section-title', ((sect_info = sections_by_page[pgnum])[:title] || '')
3602
- doc.set_attr 'section-or-chapter-title', (sect_info[:title] || chap_info[:title] || '')
3603
-
3604
- stamp stamp_names[side] if stamp_names
3605
-
3606
- theme_font periphery do
3607
- canvas do
3608
- bounding_box [trim_styles[:content_left][side], trim_styles[:top][side]], width: trim_styles[:content_width][side], height: trim_styles[:height] do
3609
- if trim_styles[:column_rule_color] && (trim_column_rule_width = trim_styles[:column_rule_width]) > 0
3610
- trim_column_rule_spacing = trim_styles[:column_rule_spacing]
3611
- else
3612
- trim_column_rule_width = nil
3613
- end
3614
- prev_position = nil
3615
- ColumnPositions.each do |position|
3616
- next unless (content = content_by_position[position])
3617
- next unless (colspec = colspec_by_position[position])[:width] > 0
3618
- left, colwidth = colspec[:x], colspec[:width]
3619
- if trim_column_rule_width && colwidth < bounds.width
3620
- if (trim_column_rule = prev_position)
3621
- left += (trim_column_rule_spacing * 0.5)
3622
- colwidth -= trim_column_rule_spacing
3623
- else
3624
- colwidth -= (trim_column_rule_spacing * 0.5)
3625
- end
3626
- end
3627
- # FIXME: we need to have a content setting for chapter pages
3628
- if ::Array === content
3629
- redo_with_content = nil
3630
- # NOTE: float ensures cursor position is restored and returns us to current page if we overrun
3631
- float do
3632
- # NOTE: bounding_box is redundant if both vertical padding and border width are 0
3633
- bounding_box [left, bounds.top - trim_styles[:padding][side][0] - trim_styles[:content_offset]], width: colwidth, height: trim_styles[:content_height][side] do
3634
- # NOTE: image vposition respects padding; use negative image_vertical_align value to revert
3635
- image_opts = content[1].merge position: colspec[:align], vposition: trim_styles[:img_valign]
3636
- begin
3637
- image_info = image content[0], image_opts
3638
- if (image_link = content[2])
3639
- image_info = { width: image_info.scaled_width, height: image_info.scaled_height } unless image_opts[:format] == 'svg'
3640
- add_link_to_image image_link, image_info, image_opts
3641
- end
3642
- rescue
3643
- redo_with_content = image_opts[:alt]
3644
- log :warn, %(could not embed image in running content: #{content[0]}; #{$!.message})
3645
- end
3646
- end
3647
- end
3648
- if redo_with_content
3649
- content_by_position[position] = redo_with_content
3650
- redo
3651
- end
3652
- else
3653
- theme_font %(#{periphery}_#{side}_#{position}) do
3654
- # NOTE: minor optimization
3655
- if content == '{page-number}'
3656
- content = pagenums_enabled ? pgnum_label : nil
3657
- else
3658
- content = apply_subs_discretely doc, content, drop_lines_with_unresolved_attributes: true, imagesdir: @themesdir
3659
- content = transform_text content, @text_transform if @text_transform
3660
- end
3661
- formatted_text_box (parse_text content, inline_format: [normalize: true]),
3662
- at: [left, bounds.top - trim_styles[:padding][side][0] - trim_styles[:content_offset] + ((Array trim_styles[:valign])[0] == :center ? font.descender * 0.5 : 0)],
3663
- color: @font_color,
3664
- width: colwidth,
3665
- height: trim_styles[:prose_content_height][side],
3666
- align: colspec[:align],
3667
- valign: trim_styles[:valign],
3668
- leading: trim_styles[:line_metrics].leading,
3669
- final_gap: false,
3670
- overflow: :truncate
3671
- end
3672
- end
3673
- bounding_box [colspec[:x], bounds.top - trim_styles[:padding][side][0] - trim_styles[:content_offset]], width: colspec[:width], height: trim_styles[:content_height][side] do
3674
- stroke_vertical_rule trim_styles[:column_rule_color], at: bounds.left, line_style: trim_styles[:column_rule_style], line_width: trim_column_rule_width
3675
- end if trim_column_rule
3676
- prev_position = position
3677
- end
3678
- end
3679
- end
3680
- end
3140
+ # NOTE: inline_format is true by default
3141
+ def ink_prose string, opts = {}
3142
+ top_margin = (margin = (opts.delete :margin)) || (opts.delete :margin_top) || 0
3143
+ bot_margin = margin || (opts.delete :margin_bottom) || @theme.prose_margin_bottom
3144
+ if (transform = resolve_text_transform opts)
3145
+ string = transform_text string, transform
3681
3146
  end
3682
-
3683
- go_to_page prev_page_number
3684
- nil
3147
+ string = hyphenate_text string, @hyphenator if (opts.delete :hyphenate) && (defined? @hyphenator)
3148
+ # NOTE: used by extensions; ensures linked text gets formatted using the link styles
3149
+ if (anchor = opts.delete :anchor)
3150
+ string = anchor == true ? %(<a>#{string}</a>) : %(<a anchor="#{anchor}">#{string}</a>)
3151
+ end
3152
+ margin_top top_margin
3153
+ # NOTE: normalize makes endlines soft (replaces "\n" with ' ')
3154
+ inline_format_opts = { normalize: (opts.delete :normalize) != false }
3155
+ if (styles = opts.delete :styles)
3156
+ inline_format_opts[:inherited] = {
3157
+ styles: styles,
3158
+ text_decoration_color: (opts.delete :text_decoration_color),
3159
+ text_decoration_width: (opts.delete :text_decoration_width),
3160
+ }.compact
3161
+ end
3162
+ result = typeset_text string, (calc_line_metrics (opts.delete :line_height) || @base_line_height), {
3163
+ color: @font_color,
3164
+ inline_format: [inline_format_opts],
3165
+ align: @base_text_align.to_sym,
3166
+ }.merge(opts)
3167
+ margin_bottom bot_margin
3168
+ result
3685
3169
  end
3686
3170
 
3687
3171
  def allocate_running_content_layout doc, page, periphery, cache
@@ -3852,191 +3336,890 @@ module Asciidoctor
3852
3336
  end
3853
3337
  end
3854
3338
  end
3855
-
3856
- [trim_styles, colspec_dict, content_dict, stamp_names]
3339
+
3340
+ [trim_styles, colspec_dict, content_dict, stamp_names]
3341
+ end
3342
+ end
3343
+
3344
+ # TODO: delegate to ink_page_header and ink_page_footer per page
3345
+ def ink_running_content periphery, doc, skip = [1, 1], body_start_page_number = 1
3346
+ skip_pages, skip_pagenums = skip
3347
+ # NOTE: find and advance to first non-imported content page to use as model page
3348
+ return unless (content_start_page_number = state.pages[skip_pages..-1].index {|it| !it.imported_page? })
3349
+ content_start_page_number += (skip_pages + 1)
3350
+ num_pages = page_count
3351
+ prev_page_number = page_number
3352
+ go_to_page content_start_page_number
3353
+
3354
+ # FIXME: probably need to treat doctypes differently
3355
+ is_book = doc.doctype == 'book'
3356
+ header = doc.header? ? doc.header : nil
3357
+ sectlevels = (@theme[%(#{periphery}_sectlevels)] || 2).to_i
3358
+ sections = doc.find_by(context: :section) {|sect| sect.level <= sectlevels && sect != header }
3359
+ toc_title = (doc.attr 'toc-title').to_s if (toc_page_nums = @toc_extent&.page_range)
3360
+ disable_on_pages = @disable_running_content[periphery]
3361
+
3362
+ title_method = TitleStyles[@theme[%(#{periphery}_title_style)]]
3363
+ # FIXME: we need a proper model for all this page counting
3364
+ # FIXME: we make a big assumption that part & chapter start on new pages
3365
+ # index parts, chapters and sections by the physical page number on which they start
3366
+ part_start_pages = {}
3367
+ chapter_start_pages = {}
3368
+ section_start_pages = {}
3369
+ trailing_section_start_pages = {}
3370
+ sections.each do |sect|
3371
+ pgnum = (sect.attr 'pdf-page-start').to_i
3372
+ if is_book && ((sect_is_part = sect.sectname == 'part') || sect.level == 1)
3373
+ if sect_is_part
3374
+ part_start_pages[pgnum] ||= sect
3375
+ else
3376
+ chapter_start_pages[pgnum] ||= sect
3377
+ # FIXME: need a better way to indicate that part has ended
3378
+ part_start_pages[pgnum] = '' if sect.sectname == 'appendix' && !part_start_pages.empty?
3379
+ end
3380
+ else
3381
+ trailing_section_start_pages[pgnum] = sect
3382
+ section_start_pages[pgnum] ||= sect
3383
+ end
3384
+ end
3385
+
3386
+ # index parts, chapters, and sections by the physical page number on which they appear
3387
+ parts_by_page = SectionInfoByPage.new title_method
3388
+ chapters_by_page = SectionInfoByPage.new title_method
3389
+ sections_by_page = SectionInfoByPage.new title_method
3390
+ # QUESTION: should the default part be the doctitle?
3391
+ last_part = nil
3392
+ # QUESTION: should we enforce that the preamble is a preface?
3393
+ last_chap = is_book ? :pre : nil
3394
+ last_sect = nil
3395
+ sect_search_threshold = 1
3396
+ (1..num_pages).each do |pgnum|
3397
+ if (part = part_start_pages[pgnum])
3398
+ last_part = part
3399
+ last_chap = nil
3400
+ last_sect = nil
3401
+ end
3402
+ if (chap = chapter_start_pages[pgnum])
3403
+ last_chap = chap
3404
+ last_sect = nil
3405
+ end
3406
+ if (sect = section_start_pages[pgnum])
3407
+ last_sect = sect
3408
+ elsif part || chap
3409
+ sect_search_threshold = pgnum
3410
+ # NOTE: we didn't find a section on this page; look back to find last section started
3411
+ elsif last_sect
3412
+ (sect_search_threshold..(pgnum - 1)).reverse_each do |prev|
3413
+ if (sect = trailing_section_start_pages[prev])
3414
+ last_sect = sect
3415
+ break
3416
+ end
3417
+ end
3418
+ end
3419
+ parts_by_page[pgnum] = last_part
3420
+ if toc_page_nums&.cover? pgnum
3421
+ if is_book
3422
+ chapters_by_page[pgnum] = toc_title
3423
+ sections_by_page[pgnum] = nil
3424
+ else
3425
+ chapters_by_page[pgnum] = nil
3426
+ sections_by_page[pgnum] = section_start_pages[pgnum] || toc_title
3427
+ end
3428
+ toc_page_nums = nil if toc_page_nums.end == pgnum
3429
+ elsif last_chap == :pre
3430
+ chapters_by_page[pgnum] = pgnum < body_start_page_number ? doc.doctitle : (doc.attr 'preface-title', 'Preface')
3431
+ sections_by_page[pgnum] = last_sect
3432
+ else
3433
+ chapters_by_page[pgnum] = last_chap
3434
+ sections_by_page[pgnum] = last_sect
3435
+ end
3436
+ end
3437
+
3438
+ doctitle = resolve_doctitle doc, true
3439
+ # NOTE: set doctitle again so it's properly escaped
3440
+ doc.set_attr 'doctitle', doctitle.combined
3441
+ doc.set_attr 'document-title', doctitle.main
3442
+ doc.set_attr 'document-subtitle', doctitle.subtitle
3443
+ doc.set_attr 'page-count', (num_pages - skip_pagenums)
3444
+
3445
+ pagenums_enabled = doc.attr? 'pagenums'
3446
+ periphery_layout_cache = {}
3447
+ # NOTE: this block is invoked during PDF generation, after #write -> #render_file and thus after #convert_document
3448
+ repeat (content_start_page_number..num_pages), dynamic: true do
3449
+ pgnum = page_number
3450
+ # NOTE: don't write on pages which are imported / inserts (otherwise we can get a corrupt PDF)
3451
+ next if page.imported_page? || (disable_on_pages.include? pgnum)
3452
+ virtual_pgnum = pgnum - skip_pagenums
3453
+ pgnum_label = (virtual_pgnum < 1 ? (RomanNumeral.new pgnum, :lower) : virtual_pgnum).to_s
3454
+ side = page_side((@folio_placement[:basis] == :physical ? pgnum : virtual_pgnum), @folio_placement[:inverted])
3455
+ doc.set_attr 'page-layout', page.layout.to_s
3456
+
3457
+ # NOTE: running content is cached per page layout
3458
+ # QUESTION: should allocation be per side?
3459
+ trim_styles, colspec_dict, content_dict, stamp_names = allocate_running_content_layout doc, page, periphery, periphery_layout_cache
3460
+ # FIXME: we need to have a content setting for chapter pages
3461
+ content_by_position, colspec_by_position = content_dict[side], colspec_dict[side]
3462
+
3463
+ doc.set_attr 'page-number', pgnum_label if pagenums_enabled
3464
+ # QUESTION: should the fallback value be nil instead of empty string? or should we remove attribute if no value?
3465
+ doc.set_attr 'part-title', ((part_info = parts_by_page[pgnum])[:title] || '')
3466
+ if (part_numeral = part_info[:numeral])
3467
+ doc.set_attr 'part-numeral', part_numeral
3468
+ else
3469
+ doc.remove_attr 'part-numeral'
3470
+ end
3471
+ doc.set_attr 'chapter-title', ((chap_info = chapters_by_page[pgnum])[:title] || '')
3472
+ if (chap_numeral = chap_info[:numeral])
3473
+ doc.set_attr 'chapter-numeral', chap_numeral
3474
+ else
3475
+ doc.remove_attr 'chapter-numeral'
3476
+ end
3477
+ doc.set_attr 'section-title', ((sect_info = sections_by_page[pgnum])[:title] || '')
3478
+ doc.set_attr 'section-or-chapter-title', (sect_info[:title] || chap_info[:title] || '')
3479
+
3480
+ stamp stamp_names[side] if stamp_names
3481
+
3482
+ theme_font periphery do
3483
+ canvas do
3484
+ bounding_box [trim_styles[:content_left][side], trim_styles[:top][side]], width: trim_styles[:content_width][side], height: trim_styles[:height] do
3485
+ if trim_styles[:column_rule_color] && (trim_column_rule_width = trim_styles[:column_rule_width]) > 0
3486
+ trim_column_rule_spacing = trim_styles[:column_rule_spacing]
3487
+ else
3488
+ trim_column_rule_width = nil
3489
+ end
3490
+ prev_position = nil
3491
+ ColumnPositions.each do |position|
3492
+ next unless (content = content_by_position[position])
3493
+ next unless (colspec = colspec_by_position[position])[:width] > 0
3494
+ left, colwidth = colspec[:x], colspec[:width]
3495
+ if trim_column_rule_width && colwidth < bounds.width
3496
+ if (trim_column_rule = prev_position)
3497
+ left += (trim_column_rule_spacing * 0.5)
3498
+ colwidth -= trim_column_rule_spacing
3499
+ else
3500
+ colwidth -= (trim_column_rule_spacing * 0.5)
3501
+ end
3502
+ end
3503
+ # FIXME: we need to have a content setting for chapter pages
3504
+ if ::Array === content
3505
+ redo_with_content = nil
3506
+ # NOTE: float ensures cursor position is restored and returns us to current page if we overrun
3507
+ float do
3508
+ # NOTE: bounding_box is redundant if both vertical padding and border width are 0
3509
+ bounding_box [left, bounds.top - trim_styles[:padding][side][0] - trim_styles[:content_offset]], width: colwidth, height: trim_styles[:content_height][side] do
3510
+ # NOTE: image vposition respects padding; use negative image_vertical_align value to revert
3511
+ image_opts = content[1].merge position: colspec[:align], vposition: trim_styles[:img_valign]
3512
+ begin
3513
+ image_info = image content[0], image_opts
3514
+ if (image_link = content[2])
3515
+ image_info = { width: image_info.scaled_width, height: image_info.scaled_height } unless image_opts[:format] == 'svg'
3516
+ add_link_to_image image_link, image_info, image_opts
3517
+ end
3518
+ rescue
3519
+ redo_with_content = image_opts[:alt]
3520
+ log :warn, %(could not embed image in running content: #{content[0]}; #{$!.message})
3521
+ end
3522
+ end
3523
+ end
3524
+ if redo_with_content
3525
+ content_by_position[position] = redo_with_content
3526
+ redo
3527
+ end
3528
+ else
3529
+ theme_font %(#{periphery}_#{side}_#{position}) do
3530
+ # NOTE: minor optimization
3531
+ if content == '{page-number}'
3532
+ content = pagenums_enabled ? pgnum_label : nil
3533
+ else
3534
+ content = apply_subs_discretely doc, content, drop_lines_with_unresolved_attributes: true, imagesdir: @themesdir
3535
+ content = transform_text content, @text_transform if @text_transform
3536
+ end
3537
+ formatted_text_box (parse_text content, inline_format: [normalize: true]),
3538
+ at: [left, bounds.top - trim_styles[:padding][side][0] - trim_styles[:content_offset] + ((Array trim_styles[:valign])[0] == :center ? font.descender * 0.5 : 0)],
3539
+ color: @font_color,
3540
+ width: colwidth,
3541
+ height: trim_styles[:prose_content_height][side],
3542
+ align: colspec[:align],
3543
+ valign: trim_styles[:valign],
3544
+ leading: trim_styles[:line_metrics].leading,
3545
+ final_gap: false,
3546
+ overflow: :truncate
3547
+ end
3548
+ end
3549
+ bounding_box [colspec[:x], bounds.top - trim_styles[:padding][side][0] - trim_styles[:content_offset]], width: colspec[:width], height: trim_styles[:content_height][side] do
3550
+ stroke_vertical_rule trim_styles[:column_rule_color], at: bounds.left, line_style: trim_styles[:column_rule_style], line_width: trim_column_rule_width
3551
+ end if trim_column_rule
3552
+ prev_position = position
3553
+ end
3554
+ end
3555
+ end
3556
+ end
3557
+ end
3558
+
3559
+ go_to_page prev_page_number
3560
+ nil
3561
+ end
3562
+
3563
+ def ink_title_page doc
3564
+ # QUESTION: allow alignment per element on title page?
3565
+ title_text_align = (@theme.title_page_text_align || @base_text_align).to_sym
3566
+
3567
+ if @theme.title_page_logo_display != 'none' && (logo_image_path = (doc.attr 'title-logo-image') || (logo_image_from_theme = @theme.title_page_logo_image))
3568
+ if (logo_image_path.include? ':') && logo_image_path =~ ImageAttributeValueRx
3569
+ logo_image_attrs = (AttributeList.new $2).parse %w(alt width height)
3570
+ if logo_image_from_theme
3571
+ relative_to_imagesdir = false
3572
+ logo_image_path = apply_subs_discretely doc, $1, subs: [:attributes]
3573
+ logo_image_path = ThemeLoader.resolve_theme_asset logo_image_path, @themesdir unless doc.is_uri? logo_image_path
3574
+ else
3575
+ relative_to_imagesdir = true
3576
+ logo_image_path = $1
3577
+ end
3578
+ else
3579
+ logo_image_attrs = {}
3580
+ relative_to_imagesdir = false
3581
+ if logo_image_from_theme
3582
+ logo_image_path = apply_subs_discretely doc, logo_image_path, subs: [:attributes]
3583
+ logo_image_path = ThemeLoader.resolve_theme_asset logo_image_path, @themesdir unless doc.is_uri? logo_image_path
3584
+ end
3585
+ end
3586
+ if (::Asciidoctor::Image.target_and_format logo_image_path)[1] == 'pdf'
3587
+ log :error, %(PDF format not supported for title page logo image: #{logo_image_path})
3588
+ else
3589
+ logo_image_attrs['target'] = logo_image_path
3590
+ # NOTE: at the very least, title_text_align will be a valid alignment value
3591
+ logo_image_attrs['align'] = [(logo_image_attrs.delete 'align'), @theme.title_page_logo_align, title_text_align.to_s].find {|val| (BlockAlignmentNames.include? val) }
3592
+ if (logo_image_top = logo_image_attrs['top'] || @theme.title_page_logo_top)
3593
+ initial_y, @y = @y, (resolve_top logo_image_top)
3594
+ end
3595
+ # NOTE: pinned option keeps image on same page
3596
+ indent (@theme.title_page_logo_margin_left || 0), (@theme.title_page_logo_margin_right || 0) do
3597
+ # FIXME: add API to Asciidoctor for creating blocks outside of extensions
3598
+ convert_image (::Asciidoctor::Block.new doc, :image, content_model: :empty, attributes: logo_image_attrs), relative_to_imagesdir: relative_to_imagesdir, pinned: true
3599
+ end
3600
+ @y = initial_y if initial_y
3601
+ end
3602
+ end
3603
+
3604
+ # TODO: prevent content from spilling to next page
3605
+ theme_font :title_page do
3606
+ if (title_top = @theme.title_page_title_top)
3607
+ @y = resolve_top title_top
3608
+ end
3609
+ unless @theme.title_page_title_display == 'none'
3610
+ doctitle = doc.doctitle partition: true
3611
+ move_down @theme.title_page_title_margin_top || 0
3612
+ indent (@theme.title_page_title_margin_left || 0), (@theme.title_page_title_margin_right || 0) do
3613
+ theme_font :title_page_title do
3614
+ ink_prose doctitle.main, align: title_text_align, margin: 0
3615
+ end
3616
+ end
3617
+ move_down @theme.title_page_title_margin_bottom || 0
3618
+ end
3619
+ if @theme.title_page_subtitle_display != 'none' && (subtitle = (doctitle || (doc.doctitle partition: true)).subtitle)
3620
+ move_down @theme.title_page_subtitle_margin_top || 0
3621
+ indent (@theme.title_page_subtitle_margin_left || 0), (@theme.title_page_subtitle_margin_right || 0) do
3622
+ theme_font :title_page_subtitle do
3623
+ ink_prose subtitle, align: title_text_align, margin: 0
3624
+ end
3625
+ end
3626
+ move_down @theme.title_page_subtitle_margin_bottom || 0
3627
+ end
3628
+ if @theme.title_page_authors_display != 'none' && (doc.attr? 'authors')
3629
+ move_down @theme.title_page_authors_margin_top || 0
3630
+ indent (@theme.title_page_authors_margin_left || 0), (@theme.title_page_authors_margin_right || 0) do
3631
+ generic_authors_content = @theme.title_page_authors_content
3632
+ authors_content = {
3633
+ name_only: @theme.title_page_authors_content_name_only || generic_authors_content,
3634
+ with_email: @theme.title_page_authors_content_with_email || generic_authors_content,
3635
+ with_url: @theme.title_page_authors_content_with_url || generic_authors_content,
3636
+ }
3637
+ authors = doc.authors.map.with_index do |author, idx|
3638
+ with_author doc, author, idx == 0 do
3639
+ author_content_key = (url = doc.attr 'url') ? ((url.start_with? 'mailto:') ? :with_email : :with_url) : :name_only
3640
+ if (author_content = authors_content[author_content_key])
3641
+ apply_subs_discretely doc, author_content, drop_lines_with_unresolved_attributes: true, imagesdir: @themesdir
3642
+ else
3643
+ doc.attr 'author'
3644
+ end
3645
+ end
3646
+ end.join @theme.title_page_authors_delimiter
3647
+ theme_font :title_page_authors do
3648
+ ink_prose authors, align: title_text_align, margin: 0, normalize: true
3649
+ end
3650
+ end
3651
+ move_down @theme.title_page_authors_margin_bottom || 0
3652
+ end
3653
+ unless @theme.title_page_revision_display == 'none' || (revision_info = [(doc.attr? 'revnumber') ? %(#{doc.attr 'version-label'} #{doc.attr 'revnumber'}) : nil, (doc.attr 'revdate')].compact).empty?
3654
+ move_down @theme.title_page_revision_margin_top || 0
3655
+ revision_text = revision_info.join @theme.title_page_revision_delimiter
3656
+ if (revremark = doc.attr 'revremark')
3657
+ revision_text = %(#{revision_text}: #{revremark})
3658
+ end
3659
+ indent (@theme.title_page_revision_margin_left || 0), (@theme.title_page_revision_margin_right || 0) do
3660
+ theme_font :title_page_revision do
3661
+ ink_prose revision_text, align: title_text_align, margin: 0, normalize: false
3662
+ end
3663
+ end
3664
+ move_down @theme.title_page_revision_margin_bottom || 0
3665
+ end
3666
+ end
3667
+ end
3668
+
3669
+ def allocate_toc doc, toc_num_levels, toc_start_cursor, title_page_on
3670
+ toc_start_page_number = page_number
3671
+ to_page = nil
3672
+ extent = dry_run onto: self do
3673
+ to_page = (ink_toc doc, toc_num_levels, toc_start_page_number, toc_start_cursor).end
3674
+ theme_margin :block, :bottom unless title_page_on
3675
+ end
3676
+ # NOTE: patch for custom converters that allocate extra TOC pages without actually creating them
3677
+ if to_page > extent.to.page
3678
+ extent.to.page = to_page
3679
+ extent.to.cursor = bounds.height
3680
+ end
3681
+ # NOTE: reserve pages for the toc; leaves cursor on page after last page in toc
3682
+ if title_page_on
3683
+ extent.each_page { start_new_page }
3684
+ else
3685
+ extent.each_page {|first_page| start_new_page unless first_page }
3686
+ move_cursor_to extent.to.cursor
3687
+ end
3688
+ extent
3689
+ end
3690
+
3691
+ def get_entries_for_toc node
3692
+ node.sections
3693
+ end
3694
+
3695
+ # NOTE: num_front_matter_pages not used during a dry run
3696
+ def ink_toc doc, num_levels, toc_page_number, start_cursor, num_front_matter_pages = 0
3697
+ go_to_page toc_page_number unless (page_number == toc_page_number) || scratch?
3698
+ start_page_number = page_number
3699
+ move_cursor_to start_cursor
3700
+ unless (toc_title = doc.attr 'toc-title').nil_or_empty?
3701
+ theme_font_cascade [[:heading, level: 2], :toc_title] do
3702
+ toc_title_text_align = (@theme.toc_title_text_align || @theme.heading_h2_text_align || @theme.heading_text_align || @base_text_align).to_sym
3703
+ ink_general_heading doc, toc_title, align: toc_title_text_align, level: 2, outdent: true, role: :toctitle
3704
+ end
3705
+ end
3706
+ # QUESTION: should we skip this whole method if num_levels < 0?
3707
+ unless num_levels < 0
3708
+ dot_leader = theme_font :toc do
3709
+ # TODO: we could simplify by using nested theme_font :toc_dot_leader
3710
+ if (dot_leader_font_style = @theme.toc_dot_leader_font_style&.to_sym || :normal) != font_style
3711
+ font_style dot_leader_font_style
3712
+ end
3713
+ font_size @theme.toc_dot_leader_font_size
3714
+ {
3715
+ font_color: @theme.toc_dot_leader_font_color || @font_color,
3716
+ font_style: dot_leader_font_style,
3717
+ font_size: font_size,
3718
+ levels: ((dot_leader_l = @theme.toc_dot_leader_levels) == 'none' ? ::Set.new :
3719
+ (dot_leader_l && dot_leader_l != 'all' ? dot_leader_l.to_s.split.map(&:to_i).to_set : (0..num_levels).to_set)),
3720
+ text: (dot_leader_text = @theme.toc_dot_leader_content || DotLeaderTextDefault),
3721
+ width: dot_leader_text.empty? ? 0 : (rendered_width_of_string dot_leader_text),
3722
+ # TODO: spacer gives a little bit of room between dots and page number
3723
+ spacer: { text: NoBreakSpace, size: (spacer_font_size = @font_size * 0.25) },
3724
+ spacer_width: (rendered_width_of_char NoBreakSpace, size: spacer_font_size),
3725
+ }
3726
+ end
3727
+ theme_margin :toc, :top
3728
+ ink_toc_level (get_entries_for_toc doc), num_levels, dot_leader, num_front_matter_pages
3729
+ end
3730
+ # NOTE: range must be calculated relative to toc_page_number; absolute page number in scratch document is arbitrary
3731
+ toc_page_numbers = (toc_page_number..(toc_page_number + (page_number - start_page_number)))
3732
+ go_to_page page_count unless scratch?
3733
+ toc_page_numbers
3734
+ end
3735
+
3736
+ def ink_toc_level entries, num_levels, dot_leader, num_front_matter_pages
3737
+ # NOTE: font options aren't always reliable, so store size separately
3738
+ toc_font_info = theme_font :toc do
3739
+ { font: font, size: @font_size }
3740
+ end
3741
+ hanging_indent = @theme.toc_hanging_indent
3742
+ entries.each do |entry|
3743
+ next if (num_levels_for_entry = (entry.attr 'toclevels', num_levels).to_i) < (entry_level = entry.level + 1).pred ||
3744
+ !(entry_anchor = (entry.attr 'pdf-anchor') || entry.id) ||
3745
+ ((entry.option? 'notitle') && entry == entry.document.last_child && entry.empty?)
3746
+ theme_font :toc, level: entry_level do
3747
+ entry_title = entry.context == :section ? entry.numbered_title : (entry.title? ? entry.title : (entry.xreftext 'basic'))
3748
+ next if entry_title.empty?
3749
+ entry_title = transform_text entry_title, @text_transform if @text_transform
3750
+ pgnum_label_placeholder_width = rendered_width_of_string '0' * @toc_max_pagenum_digits
3751
+ # NOTE: only write title (excluding dots and page number) if this is a dry run
3752
+ if scratch?
3753
+ indent 0, pgnum_label_placeholder_width do
3754
+ # NOTE: must wrap title in empty anchor element in case links are styled with different font family / size
3755
+ ink_prose entry_title, anchor: true, normalize: false, hanging_indent: hanging_indent, normalize_line_height: true, margin: 0
3756
+ end
3757
+ else
3758
+ if !(physical_pgnum = entry.attr 'pdf-page-start') &&
3759
+ (target_page_ref = (get_dest entry_anchor)&.first) &&
3760
+ (target_page_idx = state.pages.index {|candidate| candidate.dictionary == target_page_ref })
3761
+ physical_pgnum = target_page_idx + 1
3762
+ end
3763
+ if physical_pgnum
3764
+ virtual_pgnum = physical_pgnum - num_front_matter_pages
3765
+ pgnum_label = (virtual_pgnum < 1 ? (RomanNumeral.new physical_pgnum, :lower) : virtual_pgnum).to_s
3766
+ else
3767
+ pgnum_label = '?'
3768
+ end
3769
+ start_page_number = page_number
3770
+ start_cursor = cursor
3771
+ start_dots = nil
3772
+ entry_title_inherited = (apply_text_decoration ::Set.new, :toc, entry_level).merge anchor: entry_anchor, color: @font_color
3773
+ # NOTE: use text formatter to add anchor overlay to avoid using inline format with synthetic anchor tag
3774
+ entry_title_fragments = text_formatter.format entry_title, inherited: entry_title_inherited
3775
+ line_metrics = calc_line_metrics @base_line_height
3776
+ indent 0, pgnum_label_placeholder_width do
3777
+ (entry_title_fragments[-1][:callback] ||= []) << (last_fragment_pos = ::Asciidoctor::PDF::FormattedText::FragmentPositionRenderer.new)
3778
+ typeset_formatted_text entry_title_fragments, line_metrics, hanging_indent: hanging_indent, normalize_line_height: true
3779
+ start_dots = last_fragment_pos.right + hanging_indent
3780
+ last_fragment_cursor = last_fragment_pos.top + line_metrics.padding_top
3781
+ start_cursor = last_fragment_cursor if last_fragment_pos.page_number > start_page_number || (start_cursor - last_fragment_cursor) > line_metrics.height
3782
+ end
3783
+ end_cursor = cursor
3784
+ move_cursor_to start_cursor
3785
+ # NOTE: we're guaranteed to be on the same page as the final line of the entry
3786
+ if dot_leader[:width] > 0 && (dot_leader[:levels].include? entry_level.pred)
3787
+ pgnum_label_width = rendered_width_of_string pgnum_label
3788
+ pgnum_label_font_settings = { color: @font_color, font: font_family, size: @font_size, styles: font_styles }
3789
+ save_font do
3790
+ # NOTE: the same font is used for dot leaders throughout toc
3791
+ set_font toc_font_info[:font], dot_leader[:font_size]
3792
+ font_style dot_leader[:font_style]
3793
+ num_dots = [((bounds.width - start_dots - dot_leader[:spacer_width] - pgnum_label_width) / dot_leader[:width]).floor, 0].max
3794
+ # FIXME: dots don't line up in columns if width of page numbers differ
3795
+ typeset_formatted_text [
3796
+ { text: dot_leader[:text] * num_dots, color: dot_leader[:font_color] },
3797
+ dot_leader[:spacer],
3798
+ ({ text: pgnum_label, anchor: entry_anchor }.merge pgnum_label_font_settings),
3799
+ ], line_metrics, align: :right
3800
+ end
3801
+ else
3802
+ typeset_formatted_text [{ text: pgnum_label, color: @font_color, anchor: entry_anchor }], line_metrics, align: :right
3803
+ end
3804
+ move_cursor_to end_cursor
3805
+ end
3806
+ end
3807
+ indent @theme.toc_indent do
3808
+ ink_toc_level (get_entries_for_toc entry), num_levels_for_entry, dot_leader, num_front_matter_pages
3809
+ end if num_levels_for_entry >= entry_level
3810
+ end
3811
+ end
3812
+
3813
+ # Sends the specified message to the log unless this method is called from the scratch document
3814
+ def log severity, message = nil, &block
3815
+ logger.send severity, message, &block unless scratch?
3816
+ end
3817
+
3818
+ # Insert a margin at the specified side if the cursor is not at the top of
3819
+ # the page. Start a new page if amount is greater than the remaining space on
3820
+ # the page.
3821
+ def margin amount, _side
3822
+ if (amount || 0) == 0 || at_page_top?
3823
+ 0
3824
+ elsif cursor > amount
3825
+ move_down amount
3826
+ amount
3827
+ else
3828
+ # move cursor to top of next page
3829
+ bounds.move_past_bottom
3830
+ 0
3831
+ end
3832
+ end
3833
+
3834
+ # Insert a bottom margin equal to amount unless cursor is at the top of the
3835
+ # page (not likely). Start a new page instead if amount is greater than the
3836
+ # remaining space on the page.
3837
+ def margin_bottom amount
3838
+ margin amount, :bottom
3839
+ end
3840
+
3841
+ # Insert a top margin equal to amount if cursor is not at the top of the
3842
+ # page. Start a new page instead if amount is greater than the remaining
3843
+ # space on the page.
3844
+ def margin_top amount
3845
+ margin amount, :top
3846
+ end
3847
+
3848
+ def next_enclosed_block block, descend: false
3849
+ return if (context = block.context) == :document
3850
+ parent_context = (parent = block.parent).context
3851
+ if (list_item = context == :list_item)
3852
+ return block.first_child if descend && block.blocks?
3853
+ siblings = parent.items
3854
+ else
3855
+ siblings = parent.blocks
3856
+ end
3857
+ siblings = siblings.flatten if parent_context == :dlist
3858
+ if block != siblings[-1]
3859
+ (self_idx = siblings.index block) && siblings[self_idx + 1]
3860
+ elsif parent_context == :list_item || (parent_context == :open && parent.style != 'abstract') || parent_context == :section
3861
+ next_enclosed_block parent
3862
+ elsif list_item && (grandparent = parent.parent).context == :list_item
3863
+ next_enclosed_block grandparent
3864
+ end
3865
+ end
3866
+
3867
+ def register_fonts font_catalog, fonts_dir
3868
+ return unless font_catalog
3869
+ dirs = (fonts_dir.split ValueSeparatorRx, -1).map do |dir|
3870
+ dir == 'GEM_FONTS_DIR' || dir.empty? ? ThemeLoader::FontsDir : dir
3871
+ end
3872
+ font_catalog.each do |key, styles|
3873
+ styles = styles.each_with_object({}) do |(style, path), accum|
3874
+ found = dirs.any? do |dir|
3875
+ resolved_font_path = font_path path, dir
3876
+ accum[style.to_sym] = resolved_font_path if ::File.readable? resolved_font_path
3877
+ end
3878
+ raise ::Errno::ENOENT, ((File.absolute_path? path) ? %(#{path} not found) : %(#{path} not found in #{fonts_dir.gsub ValueSeparatorRx, ' or '})) unless found
3879
+ end
3880
+ register_font key => styles
3881
+ end
3882
+ end
3883
+
3884
+ # Compute the rendered width of a char, taking fallback fonts into account
3885
+ def rendered_width_of_char char, opts = {}
3886
+ unless @fallback_fonts.empty? || (font.glyph_present? char)
3887
+ @fallback_fonts.each do |fallback_font|
3888
+ font fallback_font do
3889
+ return width_of_string char, opts if font.glyph_present? char
3890
+ end
3891
+ end
3892
+ end
3893
+ width_of_string char, opts
3894
+ end
3895
+
3896
+ # Compute the rendered width of a string, taking fallback fonts into account
3897
+ def rendered_width_of_string str, opts = {}
3898
+ opts = opts.merge kerning: default_kerning?
3899
+ if str.length == 1
3900
+ rendered_width_of_char str, opts
3901
+ elsif (chars = str.each_char).all? {|char| font.glyph_present? char }
3902
+ width_of_string str, opts
3903
+ else
3904
+ char_widths = chars.map {|char| rendered_width_of_char char, opts }
3905
+ char_widths.sum + (char_widths.length * character_spacing)
3906
+ end
3907
+ end
3908
+
3909
+ # Resolve the path and sizing of the background image either from a document attribute or theme key.
3910
+ #
3911
+ # Returns the argument list for the image method if the document attribute or theme key is found. Otherwise,
3912
+ # nothing. The first argument in the argument list is the image path. If that value is nil, the background
3913
+ # image is disabled. The second argument is the options hash to specify the dimensions, such as width and fit.
3914
+ def resolve_background_image doc, theme, key, opts = {}
3915
+ if ::String === key
3916
+ theme_key = opts.delete :theme_key
3917
+ image_path = (doc.attr key) || (from_theme = theme[theme_key || (key.tr '-', '_').to_sym])
3918
+ else
3919
+ image_path = from_theme = theme[key]
3920
+ end
3921
+ symbolic_paths = opts.delete :symbolic_paths
3922
+ if image_path
3923
+ if symbolic_paths&.include? image_path
3924
+ return [image_path, {}]
3925
+ elsif image_path == 'none'
3926
+ return []
3927
+ elsif (image_path.include? ':') && image_path =~ ImageAttributeValueRx
3928
+ image_attrs = (AttributeList.new $2).parse %w(alt width)
3929
+ if from_theme
3930
+ image_path = apply_subs_discretely doc, $1, subs: [:attributes]
3931
+ image_relative_to = @themesdir
3932
+ else
3933
+ image_path = $1
3934
+ image_relative_to = true
3935
+ end
3936
+ elsif from_theme
3937
+ image_path = apply_subs_discretely doc, image_path, subs: [:attributes]
3938
+ image_relative_to = @themesdir
3939
+ end
3940
+
3941
+ image_path, image_format = ::Asciidoctor::Image.target_and_format image_path, image_attrs
3942
+ image_path = resolve_image_path doc, image_path, image_format, image_relative_to
3943
+
3944
+ return unless image_path
3945
+
3946
+ unless ::File.readable? image_path
3947
+ log :warn, %(#{key.to_s.tr '-_', ' '} not found or readable: #{image_path})
3948
+ return
3949
+ end
3950
+
3951
+ if image_format == 'pdf'
3952
+ [image_path, page: [((image_attrs || {})['page']).to_i, 1].max, format: image_format]
3953
+ else
3954
+ [image_path, (resolve_image_options image_path, image_format, image_attrs, (({ background: true, container_size: [page_width, page_height] }.merge opts)))]
3955
+ end
3956
+ end
3957
+ end
3958
+
3959
+ def resolve_doctitle doc, partition = nil
3960
+ if doc.header?
3961
+ doc.doctitle partition: partition
3962
+ elsif partition
3963
+ ::Asciidoctor::Document::Title.new (doc.attr 'untitled-label'), separator: (doc.attr 'title-separator')
3964
+ else
3965
+ doc.attr 'untitled-label'
3966
+ end
3967
+ end
3968
+
3969
+ # Resolves the explicit width, if specified, as a PDF pt value.
3970
+ #
3971
+ # Resolves the explicit width, first considering the pdfwidth attribute, then the scaledwidth
3972
+ # attribute, then the theme default (if enabled by the :use_fallback option), and finally the
3973
+ # width attribute. If the specified value is in pixels, the value is scaled by 75% to perform
3974
+ # approximate CSS px to PDF pt conversion. If the value is a percentage, and the
3975
+ # bounds_width option is given, the percentage of the bounds_width value is returned.
3976
+ # Otherwise, the percentage width is returned.
3977
+ #--
3978
+ # QUESTION: should we enforce positive result?
3979
+ def resolve_explicit_width attrs, opts = {}
3980
+ bounds_width = opts[:bounds_width]
3981
+ # QUESTION: should we restrict width to bounds_width for pdfwidth?
3982
+ if attrs.key? 'pdfwidth'
3983
+ if (width = attrs['pdfwidth']).end_with? '%'
3984
+ bounds_width ? (width.to_f / 100) * bounds_width : width
3985
+ elsif opts[:support_vw] && (width.end_with? 'vw')
3986
+ (width.chomp 'vw').extend ViewportWidth
3987
+ else
3988
+ str_to_pt width
3989
+ end
3990
+ elsif attrs.key? 'scaledwidth'
3991
+ # NOTE: the parser automatically appends % if value is unitless
3992
+ if (width = attrs['scaledwidth']).end_with? '%'
3993
+ bounds_width ? (width.to_f / 100) * bounds_width : width
3994
+ else
3995
+ str_to_pt width
3996
+ end
3997
+ elsif opts[:use_fallback] && (width = @theme.image_width)
3998
+ if ::Numeric === width
3999
+ width
4000
+ elsif (width = width.to_s).end_with? '%'
4001
+ bounds_width ? (width.to_f / 100) * bounds_width : bounds_width
4002
+ elsif opts[:support_vw] && (width.end_with? 'vw')
4003
+ (width.chomp 'vw').extend ViewportWidth
4004
+ else
4005
+ str_to_pt width
4006
+ end
4007
+ elsif attrs.key? 'width'
4008
+ if (width = attrs['width']).end_with? '%'
4009
+ width = (width.to_f / 100) * bounds_width if bounds_width
4010
+ elsif DigitsRx.match? width
4011
+ width = to_pt width.to_f, :px
4012
+ else
4013
+ return
4014
+ end
4015
+ bounds_width && opts[:constrain_to_bounds] ? [bounds_width, width].min : width
4016
+ end
4017
+ end
4018
+
4019
+ def resolve_image_options image_path, image_format, image_attrs, opts = {}
4020
+ if image_format == 'svg'
4021
+ image_opts = {
4022
+ enable_file_requests_with_root: (::File.dirname image_path),
4023
+ enable_web_requests: allow_uri_read ? (method :load_open_uri).to_proc : false,
4024
+ cache_images: cache_uri,
4025
+ fallback_font_name: fallback_svg_font_name,
4026
+ format: 'svg',
4027
+ }
4028
+ else
4029
+ image_opts = {}
4030
+ end
4031
+ container_size = opts[:container_size]
4032
+ if image_attrs
4033
+ if (alt = image_attrs['alt'])
4034
+ image_opts[:alt] = %([#{alt}])
4035
+ end
4036
+ if (background = opts[:background]) && (image_pos = image_attrs['position']) && (image_pos = resolve_background_position image_pos, nil)
4037
+ image_opts.update image_pos
4038
+ end
4039
+ if (image_fit = image_attrs['fit'] || (background ? 'contain' : nil))
4040
+ image_fit = 'contain' if image_format == 'svg' && image_fit == 'fill'
4041
+ container_width, container_height = container_size
4042
+ case image_fit
4043
+ when 'none'
4044
+ if (image_width = resolve_explicit_width image_attrs, bounds_width: container_width)
4045
+ image_opts[:width] = image_width
4046
+ end
4047
+ when 'scale-down'
4048
+ # NOTE: if width and height aren't set in SVG, real width and height are computed after stretching viewbox to fit page
4049
+ if (image_width = resolve_explicit_width image_attrs, bounds_width: container_width)
4050
+ if image_width > container_width
4051
+ image_opts[:fit] = container_size
4052
+ else
4053
+ image_size = intrinsic_image_dimensions image_path, image_format
4054
+ if image_width * (image_size[:height].to_f / image_size[:width]) > container_height
4055
+ image_opts[:fit] = container_size
4056
+ else
4057
+ image_opts[:width] = image_width
4058
+ end
4059
+ end
4060
+ else
4061
+ image_size = intrinsic_image_dimensions image_path, image_format
4062
+ image_opts[:fit] = container_size if image_size[:width] > container_width || image_size[:height] > container_height
4063
+ end
4064
+ when 'cover'
4065
+ # QUESTION: should we take explicit width into account?
4066
+ image_size = intrinsic_image_dimensions image_path, image_format
4067
+ if container_width * (image_size[:height].to_f / image_size[:width]) < container_height
4068
+ image_opts[:height] = container_height
4069
+ else
4070
+ image_opts[:width] = container_width
4071
+ end
4072
+ when 'fill'
4073
+ image_opts[:width] = container_width
4074
+ image_opts[:height] = container_height
4075
+ else # 'contain'
4076
+ image_opts[:fit] = container_size
4077
+ end
4078
+ elsif (image_width = resolve_explicit_width image_attrs, bounds_width: container_size[0])
4079
+ image_opts[:width] = image_width
4080
+ else # default to fit=contain if sizing is not specified
4081
+ image_opts[:fit] = container_size
4082
+ end
4083
+ else
4084
+ image_opts[:fit] = container_size
3857
4085
  end
4086
+ image_opts
3858
4087
  end
3859
4088
 
3860
- def add_outline doc, num_levels, toc_page_nums, num_front_matter_pages, has_front_cover
3861
- if ::String === num_levels
3862
- if num_levels.include? ':'
3863
- num_levels, expand_levels = num_levels.split ':', 2
3864
- num_levels = num_levels.empty? ? (doc.attr 'toclevels', 2).to_i : num_levels.to_i
3865
- expand_levels = expand_levels.to_i
3866
- else
3867
- num_levels = expand_levels = num_levels.to_i
3868
- end
4089
+ # Resolve the system path of the specified image path.
4090
+ #
4091
+ # Resolve and normalize the absolute system path of the specified image,
4092
+ # taking into account the imagesdir attribute. If an image path is not
4093
+ # specified, the path is read from the target attribute of the specified
4094
+ # document node.
4095
+ #
4096
+ # If the target is a URI and the allow-uri-read attribute is set on the
4097
+ # document, read the file contents to a temporary file and return the path to
4098
+ # the temporary file. If the target is a URI and the allow-uri-read attribute
4099
+ # is not set, or the URI cannot be read, this method returns a nil value.
4100
+ #
4101
+ # When a temporary file is used, the file is stored in @tmp_files to be cleaned up after conversion.
4102
+ def resolve_image_path node, image_path, image_format, relative_to = true
4103
+ doc = node.document
4104
+ if relative_to == true
4105
+ imagesdir = nil if (imagesdir = doc.attr 'imagesdir').nil_or_empty? || imagesdir == '.' || imagesdir == './'
3869
4106
  else
3870
- expand_levels = num_levels
3871
- end
3872
- front_matter_counter = RomanNumeral.new 0, :lower
3873
- pagenum_labels = {}
3874
-
3875
- num_front_matter_pages.times do |n|
3876
- pagenum_labels[n] = { P: (::PDF::Core::LiteralString.new front_matter_counter.next!.to_s) }
3877
- end
3878
-
3879
- # add labels for each content page, which is required for reader's page navigator to work correctly
3880
- (num_front_matter_pages..(page_count - 1)).each_with_index do |n, i|
3881
- pagenum_labels[n] = { P: (::PDF::Core::LiteralString.new (i + 1).to_s) }
3882
- end
3883
-
3884
- unless toc_page_nums.none? || (toc_title = doc.attr 'toc-title').nil_or_empty?
3885
- toc_section = insert_toc_section doc, toc_title, toc_page_nums
4107
+ imagesdir = relative_to
3886
4108
  end
3887
-
3888
- outline.define do
3889
- initial_pagenum = has_front_cover ? 2 : 1
3890
- # FIXME: use sanitize: :plain_text on Document#doctitle once available
3891
- if document.page_count >= initial_pagenum && (outline_title = doc.attr 'outline-title') &&
3892
- (outline_title.empty? ? (outline_title = document.resolve_doctitle doc) : outline_title)
3893
- page title: (document.sanitize outline_title), destination: (document.dest_top initial_pagenum)
3894
- end
3895
- # QUESTION: is there any way to get add_outline_level to invoke in the context of the outline?
3896
- document.add_outline_level self, doc.sections, num_levels, expand_levels
3897
- end if doc.attr? 'outline'
3898
-
3899
- toc_section.parent.blocks.delete toc_section if toc_section
3900
-
3901
- catalog.data[:PageLabels] = state.store.ref Nums: pagenum_labels.flatten
3902
- primary_page_mode, secondary_page_mode = PageModes[(doc.attr 'pdf-page-mode') || @theme.page_mode]
3903
- catalog.data[:PageMode] = primary_page_mode
3904
- catalog.data[:NonFullScreenPageMode] = secondary_page_mode if secondary_page_mode
3905
- nil
3906
- end
3907
-
3908
- def add_outline_level outline, sections, num_levels, expand_levels
3909
- sections.each do |sect|
3910
- next if (num_levels_for_sect = (sect.attr 'outlinelevels', num_levels).to_i) < (level = sect.level)
3911
- sect_title = sanitize sect.numbered_title formal: true
3912
- sect_destination = sect.attr 'pdf-destination'
3913
- if level < num_levels_for_sect && sect.sections?
3914
- outline.section sect_title, destination: sect_destination, closed: expand_levels < 1 do
3915
- add_outline_level outline, sect.sections, num_levels_for_sect, (expand_levels - 1)
3916
- end
3917
- else
3918
- outline.page title: sect_title, destination: sect_destination
4109
+ # NOTE: base64 logic currently used for inline images
4110
+ if ::Base64 === image_path
4111
+ return @tmp_files[image_path] if @tmp_files.key? image_path
4112
+ tmp_image = ::Tempfile.create %W(image- .#{image_format})
4113
+ tmp_image.binmode unless image_format == 'svg'
4114
+ tmp_image.write ::Base64.decode64 image_path
4115
+ tmp_image.close
4116
+ @tmp_files[image_path] = tmp_image.path
4117
+ # NOTE: this will catch a classloader resource path on JRuby (e.g., uri:classloader:/path/to/image)
4118
+ elsif ::File.absolute_path? image_path
4119
+ ::File.absolute_path image_path
4120
+ elsif !(is_uri = node.is_uri? image_path) && imagesdir && (::File.absolute_path? imagesdir)
4121
+ ::File.absolute_path image_path, imagesdir
4122
+ # handle case when image is a URI
4123
+ elsif is_uri || (imagesdir && (node.is_uri? imagesdir) && (image_path = node.normalize_web_path image_path, imagesdir, false))
4124
+ if !allow_uri_read
4125
+ log :warn, %(cannot embed remote image: #{image_path} (allow-uri-read attribute not enabled))
4126
+ return
4127
+ elsif @tmp_files.key? image_path
4128
+ return @tmp_files[image_path]
3919
4129
  end
3920
- end
3921
- end
3922
-
3923
- def insert_toc_section doc, toc_title, toc_page_nums
3924
- if (doc.attr? 'toc-placement', 'macro') && (toc_node = (doc.find_by context: :toc)[0])
3925
- if (parent_section = toc_node.parent).context == :section
3926
- grandparent_section = parent_section.parent
3927
- toc_level = parent_section.level
3928
- insert_idx = (grandparent_section.blocks.index parent_section) + 1
3929
- else
3930
- grandparent_section = doc
3931
- toc_level = doc.sections[0].level
3932
- insert_idx = 0
4130
+ tmp_image = ::Tempfile.create ['image-', image_format && %(.#{image_format})]
4131
+ tmp_image.binmode if (binary = image_format != 'svg')
4132
+ begin
4133
+ load_open_uri.open_uri(image_path, (binary ? 'rb' : 'r')) {|fd| tmp_image.write fd.read }
4134
+ tmp_image.close
4135
+ @tmp_files[image_path] = tmp_image.path
4136
+ rescue
4137
+ @tmp_files[image_path] = nil
4138
+ log :warn, %(could not retrieve remote image: #{image_path}; #{$!.message})
4139
+ tmp_image.close
4140
+ unlink_tmp_file tmp_image.path
4141
+ nil
3933
4142
  end
3934
- toc_dest = toc_node.attr 'pdf-destination'
4143
+ # handle case when image is a local file
3935
4144
  else
3936
- grandparent_section = doc
3937
- toc_level = doc.sections[0].level
3938
- insert_idx = 0
3939
- toc_dest = dest_top toc_page_nums.first
4145
+ node.normalize_system_path image_path, imagesdir, nil, target_name: 'image'
3940
4146
  end
3941
- toc_section = Section.new grandparent_section, toc_level, false, attributes: { 'pdf-destination' => toc_dest }
3942
- toc_section.title = toc_title
3943
- grandparent_section.blocks.insert insert_idx, toc_section
3944
- toc_section
3945
4147
  end
3946
4148
 
3947
- def write pdf_doc, target
3948
- if target.respond_to? :write
3949
- target = ::QuantifiableStdout.new $stdout if target == $stdout
3950
- pdf_doc.render target
3951
- else
3952
- pdf_doc.render_file target
3953
- # QUESTION: restore attributes first?
3954
- @pdfmark&.generate_file target
3955
- if (quality = @optimize)
3956
- if quality.include? ','
3957
- quality, compliance = quality.split ',', 2
3958
- elsif quality.include? '/'
3959
- quality, compliance = nil, quality
4149
+ def resolve_text_align_from_role roles, query_theme: false, remove_predefined: false
4150
+ if (align_role = roles.reverse.find {|r| TextAlignmentRoles.include? r })
4151
+ roles.replace roles - TextAlignmentRoles if remove_predefined
4152
+ (align_role.slice 5, align_role.length).to_sym
4153
+ elsif query_theme
4154
+ roles.reverse.each do |role|
4155
+ if (text_align = @theme[%(role_#{role}_text_align)])
4156
+ return text_align.to_sym
3960
4157
  end
3961
- (Optimizer.new quality, pdf_doc.min_version, compliance).optimize_file target
3962
4158
  end
3963
- to_file = true
3964
- end
3965
- if !ENV['KEEP_ARTIFACTS']
3966
- remove_tmp_files
3967
- elsif to_file
3968
- scratch_target = (target.slice 0, target.length - (target_ext = ::File.extname target).length) + '-scratch' + target_ext
3969
- scratch.render_file scratch_target
4159
+ nil
3970
4160
  end
3971
- clear_scratch
3972
- nil
3973
4161
  end
3974
4162
 
3975
- def register_fonts font_catalog, fonts_dir
3976
- return unless font_catalog
3977
- dirs = (fonts_dir.split ValueSeparatorRx, -1).map do |dir|
3978
- dir == 'GEM_FONTS_DIR' || dir.empty? ? ThemeLoader::FontsDir : dir
3979
- end
3980
- font_catalog.each do |key, styles|
3981
- styles = styles.each_with_object({}) do |(style, path), accum|
3982
- found = dirs.any? do |dir|
3983
- resolved_font_path = font_path path, dir
3984
- accum[style.to_sym] = resolved_font_path if ::File.readable? resolved_font_path
3985
- end
3986
- raise ::Errno::ENOENT, ((File.absolute_path? path) ? %(#{path} not found) : %(#{path} not found in #{fonts_dir.gsub ValueSeparatorRx, ' or '})) unless found
4163
+ # Deprecated
4164
+ alias resolve_alignment_from_role resolve_text_align_from_role
4165
+
4166
+ def stamp_foreground_image doc, has_front_cover
4167
+ pages = state.pages
4168
+ if (first_page = (has_front_cover ? (pages.slice 1, pages.size) : pages).find {|it| !it.imported_page? }) &&
4169
+ (first_page_num = (pages.index first_page) + 1) &&
4170
+ (fg_image = resolve_background_image doc, @theme, 'page-foreground-image') && fg_image[0]
4171
+ go_to_page first_page_num
4172
+ create_stamp 'foreground-image' do
4173
+ canvas { image fg_image[0], ({ position: :center, vposition: :center }.merge fg_image[1]) }
4174
+ end
4175
+ stamp 'foreground-image'
4176
+ (first_page_num.next..page_count).each do |num|
4177
+ go_to_page num
4178
+ stamp 'foreground-image' unless page.imported_page?
3987
4179
  end
3988
- register_font key => styles
3989
4180
  end
3990
4181
  end
3991
4182
 
3992
- def font_path font_file, fonts_dir
3993
- # resolve relative to built-in font dir unless path is absolute
3994
- ::File.absolute_path font_file, fonts_dir
4183
+ def start_new_chapter chapter
4184
+ start_new_page unless at_page_top?
4185
+ # TODO: must call update_colors before advancing to next page if start_new_page is called in ink_chapter_title
4186
+ start_new_page if @ppbook && verso_page? && !(chapter.option? 'nonfacing')
3995
4187
  end
3996
4188
 
3997
- def fallback_svg_font_name
3998
- @theme.svg_fallback_font_family || @theme.svg_font_family || @theme.base_font_family
3999
- end
4189
+ alias start_new_part start_new_chapter
4000
4190
 
4001
- def apply_text_decoration styles, category, level = nil
4002
- if (text_decoration_style = TextDecorationStyleTable[level && @theme[%(#{category}_h#{level}_text_decoration)] || @theme[%(#{category}_text_decoration)]])
4003
- {
4004
- styles: (styles << text_decoration_style),
4005
- text_decoration_color: level && @theme[%(#{category}_h#{level}_text_decoration_color)] || @theme[%(#{category}_text_decoration_color)],
4006
- text_decoration_width: level && @theme[%(#{category}_h#{level}_text_decoration_width)] || @theme[%(#{category}_text_decoration_width)],
4007
- }.compact
4008
- else
4009
- styles.empty? ? {} : { styles: styles }
4010
- end
4011
- end
4191
+ # Returns a Boolean indicating whether the title page was created
4192
+ def start_title_page doc
4193
+ return unless doc.header? && !doc.notitle && @theme.title_page != false
4012
4194
 
4013
- def resolve_text_transform key, use_fallback = true
4014
- if (transform = ::Hash === key ? (key.delete :text_transform) : @theme[key])
4015
- transform == 'none' ? nil : transform
4016
- elsif use_fallback
4017
- @text_transform
4195
+ # NOTE: a new page may have already been started at this point, so decide what to do with it
4196
+ if page.empty?
4197
+ page.reset_content if (recycle = @ppbook ? recto_page? : true)
4198
+ elsif @ppbook && page_number > 0 && recto_page?
4199
+ start_new_page
4200
+ end
4201
+ side = page_side (recycle ? nil : page_number + 1), @folio_placement[:inverted]
4202
+ prev_bg_image = @page_bg_image[side]
4203
+ prev_bg_color = @page_bg_color
4204
+ if (bg_image = resolve_background_image doc, @theme, 'title-page-background-image')
4205
+ @page_bg_image[side] = bg_image[0] && bg_image
4018
4206
  end
4019
- end
4020
-
4021
- # QUESTION: should we pass a category as an argument?
4022
- # QUESTION: should we make this a method on the theme ostruct? (e.g., @theme.resolve_color key, fallback)
4023
- def resolve_theme_color key, fallback_color = nil, transparent_color = fallback_color
4024
- if (color = @theme[key])
4025
- color == 'transparent' ? transparent_color : color
4026
- else
4027
- fallback_color
4207
+ if (bg_color = resolve_theme_color :title_page_background_color)
4208
+ @page_bg_color = bg_color
4028
4209
  end
4210
+ recycle ? float { init_page self } : start_new_page
4211
+ @page_bg_image[side] = prev_bg_image if bg_image
4212
+ @page_bg_color = prev_bg_color if bg_color
4213
+ true
4029
4214
  end
4030
4215
 
4031
- def resolve_font_kerning keyword
4032
- FontKerningTable[keyword]
4216
+ def start_toc_page node, placement
4217
+ start_new_page unless at_page_top?
4218
+ start_new_page if @ppbook && verso_page? && !(placement == 'macro' && (node.option? 'nonfacing'))
4033
4219
  end
4034
4220
 
4035
- def theme_fill_and_stroke_bounds category, opts = {}
4036
- fill_and_stroke_bounds opts[:background_color], @theme[%(#{category}_border_color)] || @theme.base_border_color,
4037
- line_width: @theme[%(#{category}_border_width)],
4038
- line_style: (@theme[%(#{category}_border_style)]&.to_sym || :solid),
4039
- radius: @theme[%(#{category}_border_radius)]
4221
+ def supports_float_wrapping? node
4222
+ node.context == :paragraph
4040
4223
  end
4041
4224
 
4042
4225
  def theme_fill_and_stroke_block category, extent, opts = {}
@@ -4079,7 +4262,7 @@ module Asciidoctor
4079
4262
  advance_page unless first_page
4080
4263
  chunk_height = start_cursor = cursor
4081
4264
  chunk_height -= last_page.cursor if last_page
4082
- bounding_box [0, start_cursor], width: bounds.width, height: chunk_height do
4265
+ bounding_box [bounds.left, start_cursor], width: bounds.width, height: chunk_height do
4083
4266
  theme_fill_and_stroke_bounds category, background_color: bg_color
4084
4267
  unless first_page
4085
4268
  indent b_radius, b_radius do
@@ -4100,46 +4283,11 @@ module Asciidoctor
4100
4283
  nil
4101
4284
  end
4102
4285
 
4103
- # Insert a top margin equal to amount if cursor is not at the top of the
4104
- # page. Start a new page instead if amount is greater than the remaining
4105
- # space on the page.
4106
- def margin_top amount
4107
- margin amount, :top
4108
- end
4109
-
4110
- # Insert a bottom margin equal to amount unless cursor is at the top of the
4111
- # page (not likely). Start a new page instead if amount is greater than the
4112
- # remaining space on the page.
4113
- def margin_bottom amount
4114
- margin amount, :bottom
4115
- end
4116
-
4117
- # Insert a margin at the specified side if the cursor is not at the top of
4118
- # the page. Start a new page if amount is greater than the remaining space on
4119
- # the page.
4120
- def margin amount, _side
4121
- if (amount || 0) == 0 || at_page_top?
4122
- 0
4123
- # NOTE: use low-level cursor calculation to workaround cursor bug in column_box context
4124
- elsif y - reference_bounds.absolute_bottom > amount
4125
- move_down amount
4126
- amount
4127
- else
4128
- # set cursor at top of next page
4129
- reference_bounds.move_past_bottom
4130
- 0
4131
- end
4132
- end
4133
-
4134
- # Lookup margin for theme element and side, then delegate to margin method.
4135
- # If margin value is not found, assume 0.
4136
- def theme_margin category, side, node = true
4137
- if node
4138
- category = :block if node != true && node.context == :section
4139
- margin (@theme[%(#{category}_margin_#{side})] || 0), side
4140
- else
4141
- 0
4142
- end
4286
+ def theme_fill_and_stroke_bounds category, opts = {}
4287
+ fill_and_stroke_bounds opts[:background_color], @theme[%(#{category}_border_color)] || @theme.base_border_color,
4288
+ line_width: @theme[%(#{category}_border_width)],
4289
+ line_style: (@theme[%(#{category}_border_style)]&.to_sym || :solid),
4290
+ radius: @theme[%(#{category}_border_radius)]
4143
4291
  end
4144
4292
 
4145
4293
  def theme_font category, opts = {}
@@ -4198,87 +4346,15 @@ module Asciidoctor
4198
4346
  end
4199
4347
  end
4200
4348
 
4201
- # Calculate the font size (down to the minimum font size) that would allow
4202
- # all the specified fragments to fit in the available width without wrapping lines.
4203
- #
4204
- # Return the calculated font size if an adjustment is necessary or nil if no
4205
- # font size adjustment is necessary.
4206
- def compute_autofit_font_size fragments, category
4207
- arranger = arrange_fragments_by_line fragments
4208
- # NOTE: finalizing the line here generates fragments & calculates their widths using the current font settings
4209
- # NOTE: it also removes zero-width spaces
4210
- arranger.finalize_line
4211
- actual_width = width_of_fragments arranger.fragments
4212
- padding = expand_padding_value @theme[%(#{category}_padding)]
4213
- if actual_width > (available_width = bounds.width - padding[3].to_f - padding[1].to_f)
4214
- adjusted_font_size = ((available_width * font_size).to_f / actual_width).truncate 4
4215
- if (min = @theme[%(#{category}_font_size_min)] || @theme.base_font_size_min) && adjusted_font_size < min
4216
- min
4217
- else
4218
- adjusted_font_size
4219
- end
4220
- end
4221
- end
4222
-
4223
- # Arrange fragments by line in an arranger and return an unfinalized arranger.
4224
- #
4225
- # Finalizing the arranger is deferred since it must be done in the context of
4226
- # the global font settings you want applied to each fragment.
4227
- def arrange_fragments_by_line fragments, _opts = {}
4228
- arranger = ::Prawn::Text::Formatted::Arranger.new self
4229
- by_line = arranger.consumed = []
4230
- fragments.each do |fragment|
4231
- if (text = fragment[:text]) == LF || !(text.include? LF)
4232
- by_line << fragment
4233
- else
4234
- text.scan LineScanRx do |line|
4235
- by_line << (line == LF ? { text: LF } : (fragment.merge text: line))
4236
- end
4237
- end
4238
- end
4239
- arranger
4240
- end
4241
-
4242
- # Calculate the width that is needed to print all the
4243
- # fragments without wrapping any lines.
4244
- #
4245
- # This method assumes endlines are represented as discrete entries in the
4246
- # fragments array.
4247
- def width_of_fragments fragments
4248
- line_widths = [0]
4249
- fragments.each do |fragment|
4250
- if fragment.text == LF
4251
- line_widths << 0
4252
- else
4253
- line_widths[-1] += fragment.width
4254
- end
4255
- end
4256
- line_widths.max
4257
- end
4258
-
4259
- # Compute the rendered width of a string, taking fallback fonts into account
4260
- def rendered_width_of_string str, opts = {}
4261
- opts = opts.merge kerning: default_kerning?
4262
- if str.length == 1
4263
- rendered_width_of_char str, opts
4264
- elsif (chars = str.each_char).all? {|char| font.glyph_present? char }
4265
- width_of_string str, opts
4349
+ # Lookup margin for theme element and side, then delegate to margin method.
4350
+ # If margin value is not found, assume 0.
4351
+ def theme_margin category, side, node = true
4352
+ if node
4353
+ category = :block if node != true && node.context == :section
4354
+ margin (@theme[%(#{category}_margin_#{side})] || 0), side
4266
4355
  else
4267
- char_widths = chars.map {|char| rendered_width_of_char char, opts }
4268
- char_widths.sum + (char_widths.length * character_spacing)
4269
- end
4270
- end
4271
-
4272
- # Compute the rendered width of a char, taking fallback fonts into account
4273
- def rendered_width_of_char char, opts = {}
4274
- unless @fallback_fonts.empty? || (font.glyph_present? char)
4275
- @fallback_fonts.each do |fallback_font|
4276
- font fallback_font do
4277
- return width_of_string char, opts if font.glyph_present? char
4278
- end
4279
- end
4356
+ 0
4280
4357
  end
4281
- width_of_string char, opts
4282
4358
  end
4283
4359
 
4284
4360
  # TODO: document me, esp the first line formatting functionality
@@ -4286,7 +4362,7 @@ module Asciidoctor
4286
4362
  opts = { leading: line_metrics.leading, final_gap: line_metrics.final_gap }.merge opts
4287
4363
  string = string.gsub CjkLineBreakRx, ZeroWidthSpace if @cjk_line_breaks
4288
4364
  return text_box string, opts if opts[:height]
4289
- move_down line_metrics.padding_top
4365
+ opts[:initial_gap] = line_metrics.padding_top
4290
4366
  if (hanging_indent = (opts.delete :hanging_indent) || 0) > 0
4291
4367
  indent hanging_indent do
4292
4368
  text string, (opts.merge indent_paragraphs: -hanging_indent)
@@ -4302,8 +4378,7 @@ module Asciidoctor
4302
4378
 
4303
4379
  # QUESTION: combine with typeset_text?
4304
4380
  def typeset_formatted_text fragments, line_metrics, opts = {}
4305
- move_down line_metrics.padding_top
4306
- opts = { leading: line_metrics.leading, final_gap: line_metrics.final_gap }.merge opts
4381
+ opts = { leading: line_metrics.leading, initial_gap: line_metrics.padding_top, final_gap: line_metrics.final_gap }.merge opts
4307
4382
  if (hanging_indent = (opts.delete :hanging_indent) || 0) > 0
4308
4383
  indent hanging_indent do
4309
4384
  formatted_text fragments, (opts.merge indent_paragraphs: -hanging_indent)
@@ -4314,94 +4389,67 @@ module Asciidoctor
4314
4389
  move_down line_metrics.padding_bottom
4315
4390
  end
4316
4391
 
4317
- def height_of_typeset_text string, opts = {}
4318
- line_metrics = (calc_line_metrics opts[:line_height] || @base_line_height)
4319
- (height_of string, leading: line_metrics.leading, final_gap: line_metrics.final_gap) + line_metrics.padding_top + (opts[:single_line] ? 0 : line_metrics.padding_bottom)
4320
- end
4321
-
4322
- # NOTE: only used when tabsize attribute is not specified
4323
- # tabs must always be replaced with spaces in order for the indentation guards to work
4324
- def expand_tabs string
4325
- if string.nil_or_empty?
4326
- ''
4327
- elsif string.include? TAB
4328
- full_tab_space = ' ' * (tab_size = 4)
4329
- (string.split LF, -1).map do |line|
4330
- if line.empty? || !(tab_idx = line.index TAB)
4331
- line
4332
- else
4333
- if tab_idx == 0
4334
- leading_tabs = 0
4335
- line.each_byte do |b|
4336
- break unless b == 9
4337
- leading_tabs += 1
4338
- end
4339
- line = %(#{full_tab_space * leading_tabs}#{rest = line.slice leading_tabs, line.length})
4340
- next line unless rest.include? TAB
4341
- end
4342
- # keeps track of how many spaces were added to adjust offset in match data
4343
- spaces_added = 0
4344
- idx = 0
4345
- result = ''
4346
- line.each_char do |c|
4347
- if c == TAB
4348
- # calculate how many spaces this tab represents, then replace tab with spaces
4349
- if (offset = idx + spaces_added) % tab_size == 0
4350
- spaces_added += (tab_size - 1)
4351
- result += full_tab_space
4352
- else
4353
- unless (spaces = tab_size - offset % tab_size) == 1
4354
- spaces_added += (spaces - 1)
4355
- end
4356
- result += (' ' * spaces)
4357
- end
4358
- else
4359
- result += c
4360
- end
4361
- idx += 1
4362
- end
4363
- result
4364
- end
4365
- end.join LF
4392
+ def write pdf_doc, target
4393
+ if target.respond_to? :write
4394
+ target = ::QuantifiableStdout.new $stdout if target == $stdout
4395
+ pdf_doc.render target
4366
4396
  else
4367
- string
4397
+ pdf_doc.render_file target
4398
+ # QUESTION: restore attributes first?
4399
+ @pdfmark&.generate_file target
4400
+ if (quality = @optimize)
4401
+ if quality.include? ','
4402
+ quality, compliance = quality.split ',', 2
4403
+ elsif quality.include? '/'
4404
+ quality, compliance = nil, quality
4405
+ end
4406
+ (Optimizer.new quality, pdf_doc.min_version, compliance).optimize_file target
4407
+ end
4408
+ to_file = true
4409
+ end
4410
+ if !ENV['KEEP_ARTIFACTS']
4411
+ remove_tmp_files
4412
+ elsif to_file
4413
+ scratch_target = (target.slice 0, target.length - (target_ext = ::File.extname target).length) + '-scratch' + target_ext
4414
+ scratch.render_file scratch_target
4368
4415
  end
4416
+ clear_scratch
4417
+ nil
4369
4418
  end
4370
4419
 
4371
- # Add an indentation guard at the start of indented lines.
4372
- # Expand tabs to spaces if tabs are present
4373
- def guard_indentation string
4374
- unless (string = expand_tabs string).empty?
4375
- string[0] = GuardedIndent if string.start_with? ' '
4376
- string.gsub! InnerIndent, GuardedInnerIndent if string.include? InnerIndent
4420
+ # Deprecated method names
4421
+ alias layout_footnotes ink_footnotes
4422
+ alias layout_title_page ink_title_page
4423
+ alias layout_cover_page ink_cover_page
4424
+ alias layout_chapter_title ink_chapter_title
4425
+ alias layout_part_title ink_part_title
4426
+ alias layout_general_heading ink_general_heading
4427
+ alias layout_heading ink_heading
4428
+ alias layout_prose ink_prose
4429
+ alias layout_caption ink_caption
4430
+ alias layout_table_caption ink_table_caption
4431
+ alias layout_toc ink_toc
4432
+ alias layout_toc_level ink_toc_level
4433
+ alias layout_running_content ink_running_content
4434
+
4435
+ # intercepts "class CustomPDFConverter < (Asciidoctor::Converter.for 'pdf')"
4436
+ def self.method_added method_sym
4437
+ if (method_name = method_sym.to_s).start_with? 'layout_'
4438
+ alias_method %(ink_#{method_name.slice 7, method_name.length}).to_sym, method_sym
4439
+ elsif method_name == 'convert_listing_or_literal' || method_name == 'convert_code'
4440
+ alias_method :convert_listing, method_sym
4441
+ alias_method :convert_literal, method_sym
4377
4442
  end
4378
- string
4379
4443
  end
4380
4444
 
4381
- def guard_indentation_in_fragments fragments
4382
- start_of_line = true
4383
- fragments.each do |fragment|
4384
- next if (text = fragment[:text]).empty?
4385
- if start_of_line && (text.start_with? ' ')
4386
- fragment[:text] = GuardedIndent + (((text = text.slice 1, text.length).include? InnerIndent) ? (text.gsub InnerIndent, GuardedInnerIndent) : text)
4387
- elsif text.include? InnerIndent
4388
- fragment[:text] = text.gsub InnerIndent, GuardedInnerIndent
4389
- end
4390
- start_of_line = text.end_with? LF
4391
- end
4392
- fragments
4445
+ # intercepts "(Asciidoctor::Converter.for 'pdf').prepend CustomConverterExtensions"
4446
+ def self.prepend *mods
4447
+ super
4448
+ mods.each {|mod| (mod.instance_methods false).each {|method| method_added method } }
4449
+ self
4393
4450
  end
4394
4451
 
4395
- # Derive a PDF-safe, ASCII-only anchor name from the given value.
4396
- # Encodes value into hex if it contains characters outside the ASCII range.
4397
- # If value is nil, derive an anchor name from the default_value, if given.
4398
- def derive_anchor_from_id value, default_value = nil
4399
- if value
4400
- value.ascii_only? ? value : %(0x#{::PDF::Core.string_to_hex value})
4401
- else
4402
- %(__anchor-#{default_value})
4403
- end
4404
- end
4452
+ private
4405
4453
 
4406
4454
  def add_dest_for_top doc
4407
4455
  unless (top_page = doc.attr 'pdf-page-start') > page_count
@@ -4414,302 +4462,359 @@ module Asciidoctor
4414
4462
  nil
4415
4463
  end
4416
4464
 
4417
- # If an id is provided or the node passed as the first argument has an id,
4418
- # add a named destination to the document equivalent to the node id at the
4419
- # current y position. If the node does not have an id and an id is not
4420
- # specified, do nothing.
4421
- #
4422
- # If the node is a section, and the current y position is the top of the
4423
- # page, set the y position equal to the page height to improve the navigation
4424
- # experience. If the current x position is at or inside the left margin, set
4425
- # the x position equal to 0 (left edge of page) to improve the navigation
4426
- # experience.
4427
- def add_dest_for_block node, id: nil, y: nil
4428
- if !scratch? && (id ||= node.id)
4429
- dest_x = bounds.absolute_left.truncate 4
4430
- # QUESTION: when content is aligned to left margin, should we keep precise x value or just use 0?
4431
- dest_x = 0 if dest_x <= page_margin_left
4432
- unless (dest_y = y)
4433
- dest_y = @y
4434
- dest_y += [page_height - dest_y, -@theme.block_anchor_top.to_f].min
4465
+ def add_link_to_image uri, image_info, image_opts
4466
+ image_width = image_info[:width]
4467
+ image_height = image_info[:height]
4468
+
4469
+ case image_opts[:position]
4470
+ when :center
4471
+ image_x = bounds.left_side + (bounds.width - image_width) * 0.5
4472
+ when :right
4473
+ image_x = bounds.right_side - image_width
4474
+ else # :left, nil
4475
+ image_x = bounds.left_side
4476
+ end
4477
+
4478
+ case image_opts[:vposition]
4479
+ when :top
4480
+ image_y = bounds.absolute_top
4481
+ when :center
4482
+ image_y = bounds.absolute_top - (bounds.height - image_height) * 0.5
4483
+ when :bottom
4484
+ image_y = bounds.absolute_bottom + image_height
4485
+ else
4486
+ image_y = y - image_opts[:vposition]
4487
+ end unless (image_y = image_opts[:y])
4488
+
4489
+ link_annotation [image_x, (image_y - image_height), (image_x + image_width), image_y], Border: [0, 0, 0], A: { Type: :Action, S: :URI, URI: uri.as_pdf }
4490
+ end
4491
+
4492
+ def admonition_icon_data key
4493
+ if (icon_data = @theme[%(admonition_icon_#{key})])
4494
+ icon_data = (AdmonitionIcons[key] || {}).merge icon_data
4495
+ if (icon_name = icon_data[:name])
4496
+ unless icon_name.start_with?(*IconSetPrefixes)
4497
+ log(:info) { %(#{key} admonition in theme uses icon from deprecated fa icon set; use fas, far, or fab instead) }
4498
+ icon_data[:name] = %(fa-#{icon_name}) unless icon_name.start_with? 'fa-'
4499
+ end
4500
+ else
4501
+ icon_data[:name] = AdmonitionIcons[:note][:name]
4435
4502
  end
4436
- # TODO: find a way to store only the ref of the destination; look it up when we need it
4437
- node.set_attr 'pdf-destination', (node_dest = (dest_xyz dest_x, dest_y))
4438
- add_dest id, node_dest
4503
+ else
4504
+ (icon_data = AdmonitionIcons[key] || {})[:name] ||= AdmonitionIcons[:note][:name]
4439
4505
  end
4440
- nil
4506
+ icon_data
4441
4507
  end
4442
4508
 
4443
- def resolve_doctitle doc, partition = nil
4444
- if doc.header?
4445
- doc.doctitle partition: partition
4446
- elsif partition
4447
- ::Asciidoctor::Document::Title.new (doc.attr 'untitled-label'), separator: (doc.attr 'title-separator')
4509
+ def allocate_space_for_list_item line_metrics
4510
+ advance_page if !at_page_top? && cursor < line_metrics.height + line_metrics.leading + line_metrics.padding_top
4511
+ end
4512
+
4513
+ def apply_text_decoration styles, category, level = nil
4514
+ if (text_decoration_style = TextDecorationStyleTable[level && @theme[%(#{category}_h#{level}_text_decoration)] || @theme[%(#{category}_text_decoration)]])
4515
+ {
4516
+ styles: (styles << text_decoration_style),
4517
+ text_decoration_color: level && @theme[%(#{category}_h#{level}_text_decoration_color)] || @theme[%(#{category}_text_decoration_color)],
4518
+ text_decoration_width: level && @theme[%(#{category}_h#{level}_text_decoration_width)] || @theme[%(#{category}_text_decoration_width)],
4519
+ }.compact
4448
4520
  else
4449
- doc.attr 'untitled-label'
4521
+ styles.empty? ? {} : { styles: styles }
4450
4522
  end
4451
4523
  end
4452
4524
 
4453
- def resolve_text_align_from_role roles, query_theme: false, remove_predefined: false
4454
- if (align_role = roles.reverse.find {|r| TextAlignmentRoles.include? r })
4455
- roles.replace roles - TextAlignmentRoles if remove_predefined
4456
- (align_role.slice 5, align_role.length).to_sym
4457
- elsif query_theme
4458
- roles.reverse.each do |role|
4459
- if (align = @theme[%(role_#{role}_text_align)])
4460
- return align.to_sym
4525
+ # Arrange fragments by line in an arranger and return an unfinalized arranger.
4526
+ #
4527
+ # Finalizing the arranger is deferred since it must be done in the context of
4528
+ # the global font settings you want applied to each fragment.
4529
+ def arrange_fragments_by_line fragments, _opts = {}
4530
+ arranger = ::Prawn::Text::Formatted::Arranger.new self
4531
+ by_line = arranger.consumed = []
4532
+ fragments.each do |fragment|
4533
+ if (text = fragment[:text]) == LF || !(text.include? LF)
4534
+ by_line << fragment
4535
+ else
4536
+ text.scan LineScanRx do |line|
4537
+ by_line << (line == LF ? { text: LF } : (fragment.merge text: line))
4461
4538
  end
4462
4539
  end
4463
- nil
4464
4540
  end
4541
+ arranger
4465
4542
  end
4466
4543
 
4467
- # Deprecated
4468
- alias resolve_alignment_from_role resolve_text_align_from_role
4544
+ # NOTE: assume URL is escaped (i.e., contains character references such as &amp;)
4545
+ def breakable_uri uri
4546
+ scheme, address = uri.split UriSchemeBoundaryRx, 2
4547
+ address, scheme = scheme, address unless address
4548
+ unless address.nil_or_empty?
4549
+ address = address.gsub UriBreakCharsRx, UriBreakCharRepl
4550
+ # NOTE: require at least two characters after a break
4551
+ address.slice!(-2) if address[-2] == ZeroWidthSpace
4552
+ end
4553
+ %(#{scheme}#{address})
4554
+ end
4469
4555
 
4470
- # QUESTION: is this method still necessary?
4471
- def resolve_imagesdir doc
4472
- if (imagesdir = doc.attr 'imagesdir').nil_or_empty? || (imagesdir = imagesdir.chomp '/') == '.'
4473
- nil
4556
+ # Calculate the font size (down to the minimum font size) that would allow
4557
+ # all the specified fragments to fit in the available width without wrapping lines.
4558
+ #
4559
+ # Return the calculated font size if an adjustment is necessary or nil if no
4560
+ # font size adjustment is necessary.
4561
+ def compute_autofit_font_size fragments, category
4562
+ arranger = arrange_fragments_by_line fragments
4563
+ # NOTE: finalizing the line here generates fragments & calculates their widths using the current font settings
4564
+ # NOTE: it also removes zero-width spaces
4565
+ arranger.finalize_line
4566
+ actual_width = width_of_fragments arranger.fragments
4567
+ padding = expand_padding_value @theme[%(#{category}_padding)]
4568
+ if actual_width > (available_width = bounds.width - padding[3].to_f - padding[1].to_f)
4569
+ adjusted_font_size = ((available_width * font_size).to_f / actual_width).truncate 4
4570
+ if (min = @theme[%(#{category}_font_size_min)] || @theme.base_font_size_min) && adjusted_font_size < min
4571
+ min
4572
+ else
4573
+ adjusted_font_size
4574
+ end
4575
+ end
4576
+ end
4577
+
4578
+ def consolidate_ranges nums
4579
+ if nums.size > 1
4580
+ prev = nil
4581
+ nums.each_with_object [] do |num, accum|
4582
+ if prev && (prev.to_i + 1) == num.to_i
4583
+ accum[-1][1] = num
4584
+ else
4585
+ accum << [num]
4586
+ end
4587
+ prev = num
4588
+ end.map {|range| range.join '-' }
4474
4589
  else
4475
- imagesdir
4590
+ nums
4476
4591
  end
4477
4592
  end
4478
4593
 
4479
- # Resolve the system path of the specified image path.
4480
- #
4481
- # Resolve and normalize the absolute system path of the specified image,
4482
- # taking into account the imagesdir attribute. If an image path is not
4483
- # specified, the path is read from the target attribute of the specified
4484
- # document node.
4485
- #
4486
- # If the target is a URI and the allow-uri-read attribute is set on the
4487
- # document, read the file contents to a temporary file and return the path to
4488
- # the temporary file. If the target is a URI and the allow-uri-read attribute
4489
- # is not set, or the URI cannot be read, this method returns a nil value.
4490
- #
4491
- # When a temporary file is used, the file is stored in @tmp_files to be cleaned up after conversion.
4492
- def resolve_image_path node, image_path, image_format, relative_to = true
4493
- doc = node.document
4494
- imagesdir = relative_to == true ? (resolve_imagesdir doc) : relative_to
4495
- # NOTE: base64 logic currently used for inline images
4496
- if ::Base64 === image_path
4497
- return @tmp_files[image_path] if @tmp_files.key? image_path
4498
- tmp_image = ::Tempfile.create %W(image- .#{image_format})
4499
- tmp_image.binmode unless image_format == 'svg'
4500
- tmp_image.write ::Base64.decode64 image_path
4501
- tmp_image.close
4502
- @tmp_files[image_path] = tmp_image.path
4503
- # NOTE: this will catch a classloader resource path on JRuby (e.g., uri:classloader:/path/to/image)
4504
- elsif ::File.absolute_path? image_path
4505
- ::File.absolute_path image_path
4506
- elsif !(is_uri = node.is_uri? image_path) && imagesdir && (::File.absolute_path? imagesdir)
4507
- ::File.absolute_path image_path, imagesdir
4508
- # handle case when image is a URI
4509
- elsif is_uri || (imagesdir && (node.is_uri? imagesdir) && (image_path = node.normalize_web_path image_path, imagesdir, false))
4510
- if !allow_uri_read
4511
- log :warn, %(cannot embed remote image: #{image_path} (allow-uri-read attribute not enabled))
4512
- return
4513
- elsif @tmp_files.key? image_path
4514
- return @tmp_files[image_path]
4594
+ def conum_glyph number
4595
+ @conum_glyphs[number - 1]
4596
+ end
4597
+
4598
+ # Derive a PDF-safe, ASCII-only anchor name from the given value.
4599
+ # Encodes value into hex if it contains characters outside the ASCII range.
4600
+ # If value is nil, derive an anchor name from the default_value, if given.
4601
+ def derive_anchor_from_id value, default_value = nil
4602
+ if value
4603
+ value.ascii_only? ? value : %(0x#{::PDF::Core.string_to_hex value})
4604
+ else
4605
+ %(__anchor-#{default_value})
4606
+ end
4607
+ end
4608
+
4609
+ def draw_image_border top, w, h, alignment
4610
+ if (Array @theme.image_border_width).any? {|it| it&.> 0 } && (@theme.image_border_color || @theme.base_border_color)
4611
+ if (@theme.image_border_fit || 'content') == 'auto'
4612
+ bb_width = bounds.width
4613
+ elsif alignment == :center
4614
+ bb_x = (bounds.width - w) * 0.5
4615
+ elsif alignment == :right
4616
+ bb_x = bounds.width - w
4515
4617
  end
4516
- tmp_image = ::Tempfile.create ['image-', image_format && %(.#{image_format})]
4517
- tmp_image.binmode if (binary = image_format != 'svg')
4518
- begin
4519
- load_open_uri.open_uri(image_path, (binary ? 'rb' : 'r')) {|fd| tmp_image.write fd.read }
4520
- tmp_image.close
4521
- @tmp_files[image_path] = tmp_image.path
4522
- rescue
4523
- @tmp_files[image_path] = nil
4524
- log :warn, %(could not retrieve remote image: #{image_path}; #{$!.message})
4525
- tmp_image.close
4526
- unlink_tmp_file tmp_image.path
4527
- nil
4618
+ bounding_box [(bb_x || 0), top], width: (bb_width || w), height: h, position: alignment do
4619
+ theme_fill_and_stroke_bounds :image
4528
4620
  end
4529
- # handle case when image is a local file
4530
- else
4531
- node.normalize_system_path image_path, imagesdir, nil, target_name: 'image'
4621
+ true
4532
4622
  end
4533
4623
  end
4534
4624
 
4535
- def resolve_icon_image_path node, type, resolve = true
4536
- if (data_uri_enabled = (doc = node.document).attr? 'data-uri')
4537
- doc.remove_attr 'data-uri'
4625
+ # Reduce icon height to fit inside bounds.height. Icons will not render
4626
+ # properly if they are larger than the current bounds.height.
4627
+ def fit_icon_to_bounds preferred_size
4628
+ (max_height = bounds.height) < preferred_size ? max_height : preferred_size
4629
+ end
4630
+
4631
+ def font_path font_file, fonts_dir
4632
+ # resolve relative to built-in font dir unless path is absolute
4633
+ ::File.absolute_path font_file, fonts_dir
4634
+ end
4635
+
4636
+ def generate_manname_section node
4637
+ title = node.attr 'manname-title', 'Name'
4638
+ if (next_section_title = node.sections[0]&.title) && next_section_title.upcase == next_section_title
4639
+ title = title.upcase
4538
4640
  end
4641
+ sect = Section.new node, 1
4642
+ sect.sectname = 'section'
4643
+ sect.id = node.attr 'manname-id'
4644
+ sect.title = title
4645
+ sect << (Block.new sect, :paragraph, source: %(#{node.attr 'manname'} - #{node.attr 'manpurpose'}), subs: :normal)
4646
+ sect
4647
+ end
4648
+
4649
+ def get_char code
4650
+ (code.start_with? '\u') ? ([((code.slice 2, code.length).to_i 16)].pack 'U1') : code
4651
+ end
4652
+
4653
+ def get_icon_image_path node, type, resolve = true
4654
+ doc = node.document
4655
+ doc.remove_attr 'data-uri' if (data_uri_enabled = doc.attr? 'data-uri')
4539
4656
  # NOTE: icon_uri will consider icon attribute on node first, then type
4540
4657
  icon_path, icon_format = ::Asciidoctor::Image.target_and_format node.icon_uri type
4541
4658
  doc.set_attr 'data-uri', '' if data_uri_enabled
4542
4659
  resolve ? (resolve_image_path node, icon_path, icon_format, nil) : icon_path
4543
4660
  end
4544
4661
 
4545
- # Resolve the path and sizing of the background image either from a document attribute or theme key.
4546
- #
4547
- # Returns the argument list for the image method if the document attribute or theme key is found. Otherwise,
4548
- # nothing. The first argument in the argument list is the image path. If that value is nil, the background
4549
- # image is disabled. The second argument is the options hash to specify the dimensions, such as width and fit.
4550
- def resolve_background_image doc, theme, key, opts = {}
4551
- if ::String === key
4552
- theme_key = opts.delete :theme_key
4553
- image_path = (doc.attr key) || (from_theme = theme[theme_key || (key.tr '-', '_').to_sym])
4554
- else
4555
- image_path = from_theme = theme[key]
4662
+ def init_float_box _node, block_width, block_height, float_to
4663
+ gap = ::Array === (gap = @theme.image_float_gap) ? gap.dup : [gap, gap]
4664
+ float_w = block_width + (gap[0] ||= 12)
4665
+ float_h = block_height + (gap[1] ||= 6)
4666
+ box_l = bounds.left + (float_to == 'right' ? 0 : float_w)
4667
+ box_t = cursor + block_height
4668
+ box_w = bounds.width - float_w
4669
+ box_r = box_l + box_w
4670
+ box_h = [box_t, float_h].min
4671
+ box_b = box_t - box_h
4672
+ move_cursor_to box_t
4673
+ @float_box = { page: page_number, top: box_t, right: box_r, bottom: box_b, left: box_l, width: box_w, height: box_h, gap: gap }
4674
+ end
4675
+
4676
+ # NOTE: init_page is called within a float context; this will suppress prawn-svg messing with the cursor
4677
+ # NOTE: init_page is not called for imported pages, front and back cover pages, and other image pages
4678
+ def init_page *_args
4679
+ next_page_side = page_side nil, @folio_placement[:inverted]
4680
+ if @media == 'prepress' && (next_page_margin = @page_margin_by_side[page_number == 1 ? :cover : next_page_side]) != page_margin
4681
+ set_page_margin next_page_margin
4556
4682
  end
4557
- symbolic_paths = opts.delete :symbolic_paths
4558
- if image_path
4559
- if symbolic_paths&.include? image_path
4560
- return [image_path, {}]
4561
- elsif image_path == 'none'
4562
- return []
4563
- elsif (image_path.include? ':') && image_path =~ ImageAttributeValueRx
4564
- image_attrs = (AttributeList.new $2).parse %w(alt width)
4565
- if from_theme
4566
- image_path = apply_subs_discretely doc, $1, subs: [:attributes]
4567
- image_relative_to = @themesdir
4683
+ unless @page_bg_color == 'FFFFFF'
4684
+ tare = true
4685
+ fill_absolute_bounds @page_bg_color
4686
+ end
4687
+ if (bg_image_path, bg_image_opts = @page_bg_image[next_page_side])
4688
+ tare = true
4689
+ begin
4690
+ if bg_image_opts[:format] == 'pdf'
4691
+ # NOTE: pages that use PDF for the background do not support a background color or running content
4692
+ # IMPORTANT: the background PDF must have the same dimensions as the current PDF
4693
+ import_page bg_image_path, (bg_image_opts.merge replace: true, advance: false, advance_if_missing: false)
4568
4694
  else
4569
- image_path = $1
4570
- image_relative_to = true
4695
+ canvas { image bg_image_path, ({ position: :center, vposition: :center }.merge bg_image_opts) }
4571
4696
  end
4572
- elsif from_theme
4573
- image_path = apply_subs_discretely doc, image_path, subs: [:attributes]
4574
- image_relative_to = @themesdir
4697
+ rescue
4698
+ facing_page_side = (PageSides - [next_page_side])[0]
4699
+ @page_bg_image[facing_page_side] = nil if @page_bg_image[facing_page_side] == @page_bg_image[next_page_side]
4700
+ @page_bg_image[next_page_side] = nil
4701
+ log :warn, %(could not embed page background image: #{bg_image_path}; #{$!.message})
4575
4702
  end
4703
+ end
4704
+ page.tare_content_stream if tare
4705
+ end
4576
4706
 
4577
- image_path, image_format = ::Asciidoctor::Image.target_and_format image_path, image_attrs
4578
- image_path = resolve_image_path doc, image_path, image_format, image_relative_to
4579
-
4580
- return unless image_path
4581
-
4582
- unless ::File.readable? image_path
4583
- log :warn, %(#{key.to_s.tr '-_', ' '} not found or readable: #{image_path})
4584
- return
4707
+ def ink_paragraph_in_float_box node, float_box, prose_opts, role_keys, block_next, insert_margin_bottom
4708
+ @float_box = para_font_descender = para_font_size = end_cursor = nil
4709
+ if role_keys
4710
+ line_metrics = theme_font_cascade role_keys do
4711
+ para_font_descender = font.descender
4712
+ para_font_size = font_size
4713
+ calc_line_metrics @base_line_height
4585
4714
  end
4586
-
4587
- if image_format == 'pdf'
4588
- [image_path, page: [((image_attrs || {})['page']).to_i, 1].max, format: image_format]
4715
+ else
4716
+ para_font_descender = font.descender
4717
+ para_font_size = font_size
4718
+ line_metrics = calc_line_metrics @base_line_height
4719
+ end
4720
+ # allocate the space of at least one empty line below block
4721
+ line_height_length = line_metrics.height + line_metrics.leading + line_metrics.padding_top
4722
+ start_page_number = float_box[:page]
4723
+ start_cursor = cursor
4724
+ block_bottom = (float_box_bottom = float_box[:bottom]) + float_box[:gap][1]
4725
+ # use :at to incorporate padding top from line metrics since text_box method does not apply it
4726
+ # use :final_gap to incorporate padding bottom from line metrics
4727
+ # use :draw_text_callback to track end cursor (requires applying :final_gap to result manually)
4728
+ prose_opts.update \
4729
+ at: [float_box[:left], start_cursor - line_metrics.padding_top],
4730
+ width: float_box[:width],
4731
+ height: [cursor, float_box[:height] - (float_box[:top] - start_cursor) + line_height_length].min,
4732
+ final_gap: para_font_descender + line_metrics.padding_bottom,
4733
+ draw_text_callback: (proc do |text, opts|
4734
+ draw_text! text, opts
4735
+ end_cursor = opts[:at][1] # does not include :final_gap value
4736
+ end)
4737
+ overflow_text = role_keys ?
4738
+ theme_font_cascade(role_keys) { ink_prose node.content, prose_opts } :
4739
+ (ink_prose node.content, prose_opts)
4740
+ move_cursor_to end_cursor -= prose_opts[:final_gap] if end_cursor # ink_prose with :height does not move cursor
4741
+ if overflow_text.empty?
4742
+ if block_next && (supports_float_wrapping? block_next)
4743
+ insert_margin_bottom.call
4744
+ @float_box = float_box if page_number == start_page_number && cursor > start_cursor - prose_opts[:height]
4745
+ elsif end_cursor > block_bottom
4746
+ move_cursor_to block_bottom
4747
+ theme_margin :block, :bottom, block_next
4589
4748
  else
4590
- [image_path, (resolve_image_options image_path, image_format, image_attrs, (({ background: true, container_size: [page_width, page_height] }.merge opts)))]
4749
+ insert_margin_bottom.call
4750
+ end
4751
+ else
4752
+ overflow_prose_opts = { align: prose_opts[:align] || @base_text_align.to_sym }
4753
+ unless end_cursor
4754
+ overflow_prose_opts[:indent_paragraphs] = prose_opts[:indent_paragraphs]
4755
+ move_cursor_to float_box_bottom if start_cursor > float_box_bottom
4591
4756
  end
4757
+ role_keys ?
4758
+ theme_font_cascade(role_keys) { typeset_formatted_text overflow_text, line_metrics, overflow_prose_opts } :
4759
+ (typeset_formatted_text overflow_text, line_metrics, overflow_prose_opts)
4760
+ insert_margin_bottom.call
4592
4761
  end
4593
4762
  end
4594
4763
 
4595
- def resolve_image_options image_path, image_format, image_attrs, opts = {}
4596
- if image_format == 'svg'
4597
- image_opts = {
4598
- enable_file_requests_with_root: (::File.dirname image_path),
4599
- enable_web_requests: allow_uri_read ? (method :load_open_uri).to_proc : false,
4600
- cache_images: cache_uri,
4601
- fallback_font_name: fallback_svg_font_name,
4602
- format: 'svg',
4603
- }
4764
+ def insert_toc_section doc, toc_title, toc_page_nums
4765
+ if (doc.attr? 'toc-placement', 'macro') && (toc_node = (doc.find_by context: :toc)[0])
4766
+ if (parent_section = toc_node.parent).context == :section
4767
+ grandparent_section = parent_section.parent
4768
+ toc_level = parent_section.level
4769
+ insert_idx = (grandparent_section.blocks.index parent_section) + 1
4770
+ else
4771
+ grandparent_section = doc
4772
+ toc_level = doc.sections[0].level
4773
+ insert_idx = 0
4774
+ end
4775
+ toc_dest = toc_node.attr 'pdf-destination'
4604
4776
  else
4605
- image_opts = {}
4777
+ grandparent_section = doc
4778
+ toc_level = doc.sections[0].level
4779
+ insert_idx = 0
4780
+ toc_dest = dest_top toc_page_nums.first
4606
4781
  end
4607
- container_size = opts[:container_size]
4608
- if image_attrs
4609
- if (alt = image_attrs['alt'])
4610
- image_opts[:alt] = %([#{alt}])
4611
- end
4612
- if (background = opts[:background]) && (image_pos = image_attrs['position']) && (image_pos = resolve_background_position image_pos, nil)
4613
- image_opts.update image_pos
4614
- end
4615
- if (image_fit = image_attrs['fit'] || (background ? 'contain' : nil))
4616
- image_fit = 'contain' if image_format == 'svg' && image_fit == 'fill'
4617
- container_width, container_height = container_size
4618
- case image_fit
4619
- when 'none'
4620
- if (image_width = resolve_explicit_width image_attrs, bounds_width: container_width)
4621
- image_opts[:width] = image_width
4622
- end
4623
- when 'scale-down'
4624
- # NOTE: if width and height aren't set in SVG, real width and height are computed after stretching viewbox to fit page
4625
- if (image_width = resolve_explicit_width image_attrs, bounds_width: container_width)
4626
- if image_width > container_width
4627
- image_opts[:fit] = container_size
4628
- else
4629
- image_size = intrinsic_image_dimensions image_path, image_format
4630
- if image_width * (image_size[:height].to_f / image_size[:width]) > container_height
4631
- image_opts[:fit] = container_size
4632
- else
4633
- image_opts[:width] = image_width
4634
- end
4635
- end
4636
- else
4637
- image_size = intrinsic_image_dimensions image_path, image_format
4638
- image_opts[:fit] = container_size if image_size[:width] > container_width || image_size[:height] > container_height
4639
- end
4640
- when 'cover'
4641
- # QUESTION: should we take explicit width into account?
4642
- image_size = intrinsic_image_dimensions image_path, image_format
4643
- if container_width * (image_size[:height].to_f / image_size[:width]) < container_height
4644
- image_opts[:height] = container_height
4645
- else
4646
- image_opts[:width] = container_width
4647
- end
4648
- when 'fill'
4649
- image_opts[:width] = container_width
4650
- image_opts[:height] = container_height
4651
- else # 'contain'
4652
- image_opts[:fit] = container_size
4653
- end
4654
- elsif (image_width = resolve_explicit_width image_attrs, bounds_width: container_size[0])
4655
- image_opts[:width] = image_width
4656
- else # default to fit=contain if sizing is not specified
4657
- image_opts[:fit] = container_size
4658
- end
4782
+ toc_section = Section.new grandparent_section, toc_level, false, attributes: { 'pdf-destination' => toc_dest }
4783
+ toc_section.title = toc_title
4784
+ grandparent_section.blocks.insert insert_idx, toc_section
4785
+ toc_section
4786
+ end
4787
+
4788
+ def load_open_uri
4789
+ if @cache_uri && !(defined? ::OpenURI::Cache) && (Helpers.require_library 'open-uri/cached', 'open-uri-cached', :warn).nil?
4790
+ # disable URI caching if library fails to load
4791
+ @cache_uri = false
4792
+ end
4793
+ ::OpenURI
4794
+ end
4795
+
4796
+ def on_image_error _reason, node, target, opts
4797
+ log :warn, opts[:message] if opts.key? :message
4798
+ alt_text_vars = { alt: (node.attr 'alt'), target: target }
4799
+ alt_text_template = @theme.image_alt_content || '%{link}[%{alt}]%{/link} | <em>%{target}</em>' # rubocop:disable Style/FormatStringToken
4800
+ return if alt_text_template.empty?
4801
+ if (link = node.attr 'link')
4802
+ alt_text_vars[:link] = %(<a href="#{link}">)
4803
+ alt_text_vars[:'/link'] = '</a>'
4659
4804
  else
4660
- image_opts[:fit] = container_size
4805
+ alt_text_vars[:link] = ''
4806
+ alt_text_vars[:'/link'] = ''
4661
4807
  end
4662
- image_opts
4808
+ theme_font :image_alt do
4809
+ ink_prose alt_text_template % alt_text_vars, align: opts[:align], margin: 0, normalize: false, single_line: true
4810
+ end
4811
+ ink_caption node, category: :image, end: :bottom if node.title?
4812
+ theme_margin :block, :bottom, (next_enclosed_block node) unless opts[:pinned]
4813
+ nil
4663
4814
  end
4664
4815
 
4665
- # Resolves the explicit width, if specified, as a PDF pt value.
4666
- #
4667
- # Resolves the explicit width, first considering the pdfwidth attribute, then the scaledwidth
4668
- # attribute, then the theme default (if enabled by the :use_fallback option), and finally the
4669
- # width attribute. If the specified value is in pixels, the value is scaled by 75% to perform
4670
- # approximate CSS px to PDF pt conversion. If the value is a percentage, and the
4671
- # bounds_width option is given, the percentage of the bounds_width value is returned.
4672
- # Otherwise, the percentage width is returned.
4673
- #--
4674
- # QUESTION: should we enforce positive result?
4675
- def resolve_explicit_width attrs, opts = {}
4676
- bounds_width = opts[:bounds_width]
4677
- # QUESTION: should we restrict width to bounds_width for pdfwidth?
4678
- if attrs.key? 'pdfwidth'
4679
- if (width = attrs['pdfwidth']).end_with? '%'
4680
- bounds_width ? (width.to_f / 100) * bounds_width : width
4681
- elsif opts[:support_vw] && (width.end_with? 'vw')
4682
- (width.chomp 'vw').extend ViewportWidth
4683
- else
4684
- str_to_pt width
4685
- end
4686
- elsif attrs.key? 'scaledwidth'
4687
- # NOTE: the parser automatically appends % if value is unitless
4688
- if (width = attrs['scaledwidth']).end_with? '%'
4689
- bounds_width ? (width.to_f / 100) * bounds_width : width
4690
- else
4691
- str_to_pt width
4692
- end
4693
- elsif opts[:use_fallback] && (width = @theme.image_width)
4694
- if ::Numeric === width
4695
- width
4696
- elsif (width = width.to_s).end_with? '%'
4697
- bounds_width ? (width.to_f / 100) * bounds_width : bounds_width
4698
- elsif opts[:support_vw] && (width.end_with? 'vw')
4699
- (width.chomp 'vw').extend ViewportWidth
4700
- else
4701
- str_to_pt width
4702
- end
4703
- elsif attrs.key? 'width'
4704
- if (width = attrs['width']).end_with? '%'
4705
- width = (width.to_f / 100) * bounds_width if bounds_width
4706
- elsif DigitsRx.match? width
4707
- width = to_pt width.to_f, :px
4708
- else
4709
- return
4710
- end
4711
- bounds_width && opts[:constrain_to_bounds] ? [bounds_width, width].min : width
4712
- end
4816
+ def remove_tmp_files
4817
+ @tmp_files.reject! {|_, path| path ? (unlink_tmp_file path) : true }
4713
4818
  end
4714
4819
 
4715
4820
  def resolve_background_position value, default_value = {}
@@ -4744,128 +4849,50 @@ module Asciidoctor
4744
4849
  end
4745
4850
  end
4746
4851
 
4747
- def resolve_top val
4748
- if val.end_with? 'vh'
4749
- page_height * (1 - (val.to_f / 100))
4750
- elsif val.end_with? '%'
4751
- @y - effective_page_height * (val.to_f / 100)
4752
- else
4753
- @y - (str_to_pt val)
4754
- end
4852
+ def resolve_font_kerning keyword
4853
+ FontKerningTable[keyword]
4755
4854
  end
4756
4855
 
4757
- def apply_subs_discretely doc, value, opts = {}
4758
- if (imagesdir = opts[:imagesdir])
4759
- imagesdir_to_restore = doc.attr 'imagesdir'
4760
- doc.set_attr 'imagesdir', imagesdir
4761
- end
4762
- # FIXME: get sub_attributes to handle drop-line w/o a warning
4763
- doc.set_attr 'attribute-missing', 'skip' unless (attribute_missing = doc.attr 'attribute-missing') == 'skip'
4764
- value = value.gsub '\{', '\\\\\\{' if (escaped_attr_ref = value.include? '\{')
4765
- value = (subs = opts[:subs]) ? (doc.apply_subs value, subs) : (doc.apply_subs value)
4766
- value = (value.split LF).delete_if {|line| SimpleAttributeRefRx.match? line }.join LF if opts[:drop_lines_with_unresolved_attributes] && (value.include? '{')
4767
- value = value.gsub '\{', '{' if escaped_attr_ref
4768
- doc.set_attr 'attribute-missing', attribute_missing unless attribute_missing == 'skip'
4769
- if imagesdir
4770
- if imagesdir_to_restore
4771
- doc.set_attr 'imagesdir', imagesdir_to_restore
4856
+ def resolve_pagenums val
4857
+ pgnums = []
4858
+ ((val.include? ',') ? (val.split ',') : (val.split ';')).each do |entry|
4859
+ if entry.include? '..'
4860
+ from, _, to = entry.partition '..'
4861
+ pgnums += ([from.to_i, 1].max..[to.to_i, 1].max).to_a
4772
4862
  else
4773
- doc.remove_attr 'imagesdir'
4863
+ pgnums << entry.to_i
4774
4864
  end
4775
4865
  end
4776
- value
4866
+
4867
+ pgnums
4777
4868
  end
4778
4869
 
4779
- def next_enclosed_block block, descend: false
4780
- return if (context = block.context) == :document
4781
- parent_context = (parent = block.parent).context
4782
- if (list_item = context == :list_item)
4783
- return block.blocks[0] if descend && block.blocks?
4784
- siblings = parent.items
4870
+ def resolve_top val
4871
+ if val.end_with? 'vh'
4872
+ page_height * (1 - (val.to_f / 100))
4873
+ elsif val.end_with? '%'
4874
+ @y - effective_page_height * (val.to_f / 100)
4785
4875
  else
4786
- siblings = parent.blocks
4787
- end
4788
- siblings = siblings.flatten if parent_context == :dlist
4789
- if block != siblings[-1]
4790
- (self_idx = siblings.index block) && siblings[self_idx + 1]
4791
- elsif parent_context == :list_item || (parent_context == :open && parent.style != 'abstract') || parent_context == :section
4792
- next_enclosed_block parent
4793
- elsif list_item && (grandparent = parent.parent).context == :list_item
4794
- next_enclosed_block grandparent
4876
+ @y - (str_to_pt val)
4795
4877
  end
4796
4878
  end
4797
4879
 
4798
- # Deprecated method names
4799
- alias layout_footnotes ink_footnotes
4800
- alias layout_title_page ink_title_page
4801
- alias layout_cover_page ink_cover_page
4802
- alias layout_chapter_title ink_chapter_title
4803
- alias layout_part_title ink_part_title
4804
- alias layout_general_heading ink_general_heading
4805
- alias layout_heading ink_heading
4806
- alias layout_prose ink_prose
4807
- alias layout_caption ink_caption
4808
- alias layout_table_caption ink_table_caption
4809
- alias layout_toc ink_toc
4810
- alias layout_toc_level ink_toc_level
4811
- alias layout_running_content ink_running_content
4812
-
4813
- # intercepts "class CustomPDFConverter < (Asciidoctor::Converter.for 'pdf')"
4814
- def self.method_added method_sym
4815
- if (method_name = method_sym.to_s).start_with? 'layout_'
4816
- alias_method %(ink_#{method_name.slice 7, method_name.length}).to_sym, method_sym
4817
- elsif method_name == 'convert_listing_or_literal' || method_name == 'convert_code'
4818
- alias_method :convert_listing, method_sym
4819
- alias_method :convert_literal, method_sym
4880
+ def resolve_text_transform key, use_fallback = true
4881
+ if (transform = ::Hash === key ? (key.delete :text_transform) : @theme[key])
4882
+ transform == 'none' ? nil : transform
4883
+ elsif use_fallback
4884
+ @text_transform
4820
4885
  end
4821
4886
  end
4822
4887
 
4823
- # intercepts "(Asciidoctor::Converter.for 'pdf').prepend CustomConverterExtensions"
4824
- def self.prepend *mods
4825
- super
4826
- mods.each {|mod| (mod.instance_methods false).each {|method| method_added method } }
4827
- self
4828
- end
4829
-
4830
- private
4831
-
4832
- def add_link_to_image uri, image_info, image_opts
4833
- image_width = image_info[:width]
4834
- image_height = image_info[:height]
4835
-
4836
- case image_opts[:position]
4837
- when :center
4838
- image_x = bounds.left_side + (bounds.width - image_width) * 0.5
4839
- when :right
4840
- image_x = bounds.right_side - image_width
4841
- else # :left, nil
4842
- image_x = bounds.left_side
4843
- end
4844
-
4845
- case image_opts[:vposition]
4846
- when :top
4847
- image_y = bounds.absolute_top
4848
- when :center
4849
- image_y = bounds.absolute_top - (bounds.height - image_height) * 0.5
4850
- when :bottom
4851
- image_y = bounds.absolute_bottom + image_height
4888
+ # QUESTION: should we pass a category as an argument?
4889
+ # QUESTION: should we make this a method on the theme ostruct? (e.g., @theme.resolve_color key, fallback)
4890
+ def resolve_theme_color key, fallback_color = nil, transparent_color = fallback_color
4891
+ if (color = @theme[key])
4892
+ color == 'transparent' ? transparent_color : color
4852
4893
  else
4853
- image_y = y - image_opts[:vposition]
4854
- end unless (image_y = image_opts[:y])
4855
-
4856
- link_annotation [image_x, (image_y - image_height), (image_x + image_width), image_y], Border: [0, 0, 0], A: { Type: :Action, S: :URI, URI: uri.as_pdf }
4857
- end
4858
-
4859
- def load_open_uri
4860
- if @cache_uri && !(defined? ::OpenURI::Cache) && (Helpers.require_library 'open-uri/cached', 'open-uri-cached', :warn).nil?
4861
- # disable URI caching if library fails to load
4862
- @cache_uri = false
4894
+ fallback_color
4863
4895
  end
4864
- ::OpenURI
4865
- end
4866
-
4867
- def remove_tmp_files
4868
- @tmp_files.reject! {|_, path| path ? (unlink_tmp_file path) : true }
4869
4896
  end
4870
4897
 
4871
4898
  def unlink_tmp_file path
@@ -4876,6 +4903,23 @@ module Asciidoctor
4876
4903
  false
4877
4904
  end
4878
4905
 
4906
+ # Calculate the width that is needed to print all the
4907
+ # fragments without wrapping any lines.
4908
+ #
4909
+ # This method assumes endlines are represented as discrete entries in the
4910
+ # fragments array.
4911
+ def width_of_fragments fragments
4912
+ line_widths = [0]
4913
+ fragments.each do |fragment|
4914
+ if fragment.text == LF
4915
+ line_widths << 0
4916
+ else
4917
+ line_widths[-1] += fragment.width
4918
+ end
4919
+ end
4920
+ line_widths.max
4921
+ end
4922
+
4879
4923
  # Promotes author to primary author attributes around block; restores original attributes after block executes
4880
4924
  def with_author doc, author, primary
4881
4925
  doc.remove_attr 'url' if (original_url = doc.attr 'url')
@@ -4908,53 +4952,7 @@ module Asciidoctor
4908
4952
  result
4909
4953
  end
4910
4954
 
4911
- # NOTE: assume URL is escaped (i.e., contains character references such as &amp;)
4912
- def breakable_uri uri
4913
- scheme, address = uri.split UriSchemeBoundaryRx, 2
4914
- address, scheme = scheme, address unless address
4915
- unless address.nil_or_empty?
4916
- address = address.gsub UriBreakCharsRx, UriBreakCharRepl
4917
- # NOTE: require at least two characters after a break
4918
- address.slice!(-2) if address[-2] == ZeroWidthSpace
4919
- end
4920
- %(#{scheme}#{address})
4921
- end
4922
-
4923
- def consolidate_ranges nums
4924
- if nums.size > 1
4925
- prev = nil
4926
- nums.each_with_object [] do |num, accum|
4927
- if prev && (prev.to_i + 1) == num.to_i
4928
- accum[-1][1] = num
4929
- else
4930
- accum << [num]
4931
- end
4932
- prev = num
4933
- end.map {|range| range.join '-' }
4934
- else
4935
- nums
4936
- end
4937
- end
4938
-
4939
- def resolve_pagenums val
4940
- pgnums = []
4941
- ((val.include? ',') ? (val.split ',') : (val.split ';')).each do |entry|
4942
- if entry.include? '..'
4943
- from, _, to = entry.partition '..'
4944
- pgnums += ([from.to_i, 1].max..[to.to_i, 1].max).to_a
4945
- else
4946
- pgnums << entry.to_i
4947
- end
4948
- end
4949
-
4950
- pgnums
4951
- end
4952
-
4953
- def get_char code
4954
- (code.start_with? '\u') ? ([((code.slice 2, code.length).to_i 16)].pack 'U1') : code
4955
- end
4956
-
4957
- def create_prototype
4955
+ def create_scratch_prototype
4958
4956
  @label = :scratch
4959
4957
  @save_state = nil
4960
4958
  @scratch_depth = 0
@@ -4971,7 +4969,7 @@ module Asciidoctor
4971
4969
  end
4972
4970
 
4973
4971
  def init_scratch originator
4974
- @prototype = originator.instance_variable_get :@prototype
4972
+ @scratch_prototype = originator.instance_variable_get :@scratch_prototype
4975
4973
  @tmp_files = originator.instance_variable_get :@tmp_files
4976
4974
  text_formatter.scratch = true
4977
4975
  self
@@ -4996,7 +4994,7 @@ module Asciidoctor
4996
4994
 
4997
4995
  def clear_scratch
4998
4996
  @scratch_depth = 0
4999
- @save_state = @prototype = @scratch = nil
4997
+ @save_state = @scratch_prototype = @scratch = nil
5000
4998
  end
5001
4999
  end
5002
5000
  end