class Asciidoctor::PDF::FormattedText::Transform

Constants

CharEntityTable
CharRefRx
HexColorRx
LF
TextDecorationTable
ThemeKeyToFragmentProperty
ZeroWidthSpace

Public Class Methods

new(options = {}) click to toggle source
# File lib/asciidoctor/pdf/formatted_text/transform.rb, line 29
def initialize options = {}
  @merge_adjacent_text_nodes = options[:merge_adjacent_text_nodes]
  # TODO: add support for character spacing
  if (theme = options[:theme])
    @theme_settings = {
      button: {
        color: theme.button_font_color,
        font: theme.button_font_family,
        size: theme.button_font_size,
        styles: (to_styles theme.button_font_style),
        background_color: (button_bg_color = theme.button_background_color),
        border_width: (button_border_width = theme.button_border_width),
        border_color: button_border_width && (theme.button_border_color || theme.base_border_color),
        border_offset: (button_border_offset = (button_bg_or_border = button_bg_color || button_border_width) && theme.button_border_offset),
        border_radius: button_bg_or_border && theme.button_border_radius,
        align: button_border_offset && :center,
        callback: button_bg_or_border && [TextBackgroundAndBorderRenderer],
      }.compact,
      code: {
        color: theme.codespan_font_color,
        font: theme.codespan_font_family,
        size: theme.codespan_font_size,
        styles: (to_styles theme.codespan_font_style),
        background_color: (mono_bg_color = theme.codespan_background_color),
        border_width: (mono_border_width = theme.codespan_border_width),
        border_color: mono_border_width && (theme.codespan_border_color || theme.base_border_color),
        border_offset: (mono_border_offset = (mono_bg_or_border = mono_bg_color || mono_border_width) && theme.codespan_border_offset),
        border_radius: mono_bg_or_border && theme.codespan_border_radius,
        align: mono_border_offset && :center,
        callback: mono_bg_or_border && [TextBackgroundAndBorderRenderer],
      }.compact,
      kbd: {
        color: theme.kbd_font_color,
        font: theme.kbd_font_family || theme.codespan_font_family,
        size: theme.kbd_font_size,
        styles: (to_styles theme.kbd_font_style),
        background_color: (kbd_bg_color = theme.kbd_background_color),
        border_width: (kbd_border_width = theme.kbd_border_width),
        border_color: kbd_border_width && (theme.kbd_border_color || theme.base_border_color),
        border_offset: (kbd_border_offset = (kbd_bg_or_border = kbd_bg_color || kbd_border_width) && theme.kbd_border_offset),
        border_radius: kbd_bg_or_border && theme.kbd_border_radius,
        align: kbd_border_offset && :center,
        callback: kbd_bg_or_border && [TextBackgroundAndBorderRenderer],
      }.compact,
      link: {
        color: theme.link_font_color,
        font: theme.link_font_family,
        size: theme.link_font_size,
        styles: (to_styles theme.link_font_style, theme.link_text_decoration),
        text_decoration_color: theme.link_text_decoration_color,
        text_decoration_width: theme.link_text_decoration_width,
        background_color: (link_bg_color = theme.link_background_color),
        border_offset: (link_border_offset = link_bg_color && theme.link_border_offset),
        align: link_border_offset && :center,
        callback: link_bg_color && [TextBackgroundAndBorderRenderer],
      }.compact,
      mark: {
        color: theme.mark_font_color,
        styles: (to_styles theme.mark_font_style),
        background_color: (mark_bg_color = theme.mark_background_color),
        border_offset: (mark_border_offset = mark_bg_color && theme.mark_border_offset),
        align: mark_border_offset && :center,
        callback: mark_bg_color && [TextBackgroundAndBorderRenderer],
      }.compact,
      menu: {
        color: theme.menu_font_color,
        font: theme.menu_font_family,
        size: theme.menu_font_size,
        styles: (to_styles theme.menu_font_style),
      }.compact,
    }
    @theme_settings.tap do |accum|
      roles_with_styles = [].to_set
      theme.each_pair do |key, val|
        next unless (key = key.to_s).start_with? 'role_'
        role, key = (key.slice 5, key.length).split '_', 2
        if (prop = ThemeKeyToFragmentProperty[key])
          (accum[role] ||= {})[prop] = val
          if key == 'border_width' && val && !(theme[%(role_#{role}_border_color)])
            accum[role][:border_color] = theme.base_border_color
          end
        #elsif key == 'font_kerning'
        #  unless (resolved_val = val == 'none' ? false : (val == 'normal' ? true : nil)).nil?
        #    (accum[role] ||= {})[:kerning] = resolved_val
        #  end
        elsif key == 'font_style' || key == 'text_decoration'
          roles_with_styles << role
        end
      end
      roles_with_styles.each do |role|
        (accum[role] ||= {})[:styles] = to_styles theme[%(role_#{role}_font_style)], theme[%(role_#{role}_text_decoration)]
      end
    end
    @theme_settings['line-through'] = { styles: [:strikethrough].to_set } unless @theme_settings.key? 'line-through'
    @theme_settings['underline'] = { styles: [:underline].to_set } unless @theme_settings.key? 'underline'
    unless @theme_settings.key? 'big'
      if (base_font_size_large = theme.base_font_size_large)
        @theme_settings['big'] = { size: %(#{(base_font_size_large / theme.base_font_size.to_f).round 5}em) }
      else
        @theme_settings['big'] = { size: '1.1667em' }
      end
    end
    unless @theme_settings.key? 'small'
      if (base_font_size_small = theme.base_font_size_small)
        @theme_settings['small'] = { size: %(#{(base_font_size_small / theme.base_font_size.to_f).round 5}em) }
      else
        @theme_settings['small'] = { size: '0.8333em' }
      end
    end
  else
    @theme_settings = {
      button: { font: 'Courier', styles: [:bold].to_set },
      code: { font: 'Courier' },
      kbd: { font: 'Courier', styles: [:italic].to_set },
      link: { color: '0000FF' },
      mark: { background_color: 'FFFF00', callback: [TextBackgroundAndBorderRenderer] },
      menu: { styles: [:bold].to_set },
      'line-through' => { styles: [:strikethrough].to_set },
      'underline' => { styles: [:underline].to_set },
      'big' => { size: '1.667em' },
      'small' => { size: '0.8333em' },
    }
  end
end

Public Instance Methods

apply(parsed, fragments = [], inherited = nil) click to toggle source
# File lib/asciidoctor/pdf/formatted_text/transform.rb, line 154
def apply parsed, fragments = [], inherited = nil
  previous_fragment_is_text = false
  # NOTE: we use each since using inject is slower than a manual loop
  parsed.each do |node|
    case node[:type]
    when :element
      # case 1: non-void element
      if node.key? :pcdata
        # NOTE: skip element if it has no children
        if (pcdata = node[:pcdata]).empty?
          # QUESTION: should this be handled by the formatter after the transform is complete?
          if previous_fragment_is_text && ((previous_fragment_text = fragments[-1][:text]).end_with? ' ')
            fragments[-1][:text] = previous_fragment_text.chop
          end
        else
          tag_name = node[:name]
          attributes = node[:attributes]
          parent = clone_fragment inherited
          fragment = build_fragment parent, tag_name, attributes
          if tag_name == :a && fragment[:type] == :indexterm && !attributes[:visible] &&
              previous_fragment_is_text && ((previous_fragment_text = fragments[-1][:text]).end_with? ' ')
            fragments[-1][:text] = previous_fragment_text.chop
          end
          if (text_transform = fragment.delete :text_transform)
            text = (text_chunks = extract_text pcdata).join
            chars = (StringIO.new transform_text text, text_transform).each_char
            restore_text pcdata, (text_chunks.each_with_object [] do |chunk, accum|
              accum << chunk.length.times.map { chars.next }.join
            end)
          end
          # NOTE: decorate child fragments with inherited properties from this element
          apply pcdata, fragments, fragment
          previous_fragment_is_text = false
        end
      # case 2: void element
      else
        case node[:name]
        when :img
          attributes = node[:attributes]
          fragment = {
            image_path: attributes[:src],
            image_format: attributes[:format],
            # a zero-width space in the text will cause the image to be duplicated
            # NOTE: add enclosing square brackets here to avoid errors in parsing
            text: %([#{attributes[:alt].delete ZeroWidthSpace}]),
            object_id: node.object_id, # used to deduplicate if fragment gets split up
          }
          if inherited && (callback = inherited[:callback]) && (callback.include? TextBackgroundAndBorderRenderer)
            # NOTE: if we keep InlineTextAligner, it needs to skip draw_text! for image fragment
            fragment[:callback] = [TextBackgroundAndBorderRenderer, InlineImageRenderer]
            fragment.update inherited.slice :border_color, :border_offset, :border_radius, :border_width, :background_color
          else
            fragment[:callback] = [InlineImageRenderer]
          end
          attributes[:class].split.each do |class_name|
            next unless @theme_settings.key? class_name
            update_fragment fragment, @theme_settings[class_name]
            if fragment[:background_color] || (fragment[:border_color] && fragment[:border_width])
              fragment[:callback] = [TextBackgroundAndBorderRenderer] | fragment[:callback]
            end
          end if attributes.key? :class
          if inherited && (link = inherited[:link])
            fragment[:link] = link
          end
          if (img_w = attributes[:width])
            fragment[:image_width] = img_w
          end
          if (img_fit = attributes[:fit])
            fragment[:image_fit] = img_fit
          end
          fragments << fragment
          previous_fragment_is_text = false
        else # :br
          if @merge_adjacent_text_nodes && previous_fragment_is_text
            fragments << (clone_fragment inherited, text: %(#{fragments.pop[:text]}#{LF}))
          else
            fragments << { text: LF }
          end
          previous_fragment_is_text = true
        end
      end
    when :charref
      if (ref_type = node[:reference_type]) == :name
        text = CharEntityTable[node[:value]]
      elsif ref_type == :decimal
        # FIXME: AFM fonts do not include a thin space glyph; set fallback_fonts to allow glyph to be resolved
        text = [node[:value]].pack 'U1'
      else
        # FIXME: AFM fonts do not include a thin space glyph; set fallback_fonts to allow glyph to be resolved
        text = [(node[:value].to_i 16)].pack 'U1'
      end
      if @merge_adjacent_text_nodes && previous_fragment_is_text
        fragments << (clone_fragment inherited, text: %(#{fragments.pop[:text]}#{text}))
      else
        fragments << (clone_fragment inherited, text: text)
      end
      previous_fragment_is_text = true
    else # :text
      if @merge_adjacent_text_nodes && previous_fragment_is_text
        fragments << (clone_fragment inherited, text: %(#{fragments.pop[:text]}#{node[:value]}))
      else
        fragments << (clone_fragment inherited, text: node[:value])
      end
      previous_fragment_is_text = true
    end
  end
  fragments
end

Private Instance Methods

build_fragment(fragment, tag_name, attrs) click to toggle source
# File lib/asciidoctor/pdf/formatted_text/transform.rb, line 265
def build_fragment fragment, tag_name, attrs
  styles = (fragment[:styles] ||= ::Set.new)
  case tag_name
  when :strong
    styles << :bold
  when :em
    styles << :italic
  when :button, :code, :kbd, :mark, :menu
    update_fragment fragment, @theme_settings[tag_name]
  when :font
    if (value = attrs[:name])
      fragment[:font] = value
    end
    if (value = attrs[:size])
      if value.end_with? 'em'
        fragment[:size] = value unless value == '1em'
      else
        fragment[:size] = value.to_f
      end
    end
    # NOTE: width is used for font-based icons
    if (value = attrs[:width])
      fragment[:width] = value
      fragment[:align] = :center
    end
    if (value = attrs[:color])
      case value.chr
      when '#' # hex string (e.g., #FF0000)
        fragment[:color] = value.length == 7 ? (value.slice 1, 6) : (value.slice 1, 3).each_char.map {|c| c * 2 }.join if HexColorRx.match? value
      when '[' # CMYK array (e.g., [50, 100, 0, 0])
        fragment[:color] = [0, 0, 0, 0].tap do |accum|
          (((value.slice 1, value.length).chomp ']').split ', ', 4).each_with_index do |it, idx|
            accum[idx] = (ival = it.to_i) == (fval = it.to_f) ? ival : fval
          end
        end
      else # assume a 6-character hex color (internal only)
        fragment[:color] = value
      end
    end
    #if (value = attrs[:character_spacing])
    #  fragment[:character_spacing] = value.to_f
    #end
  when :a
    visible = true
    # a element can have no attributes, so short-circuit if that's the case
    unless attrs.empty?
      # NOTE: href, anchor, and name are mutually exclusive; nesting is not supported
      if (value = attrs[:anchor])
        fragment[:anchor] = value
      elsif (value = attrs[:href])
        fragment[:link] = (value.include? ';') ? (value.gsub CharRefRx do
          $1 ? CharEntityTable[$1.to_sym] : [$2 ? $2.to_i : ($3.to_i 16)].pack('U1')
        end) : value
      elsif (value = attrs[:id])
        # NOTE: text is null character, which is used as placeholder text so Prawn doesn't drop fragment
        new_fragment = { name: value, callback: [InlineDestinationMarker] }
        new_fragment[:wj] = true if fragment[:wj]
        if (type = attrs[:type])
          new_fragment[:type] = type.to_sym
        end
        fragment = new_fragment
        visible = nil
      end
    end
    update_fragment fragment, @theme_settings[:link] if visible
  when :sub
    styles << :subscript
  when :sup
    styles << :superscript
  when :del
    styles << :strikethrough
  else # :span
    # NOTE: spaces in style value are superfluous for our purpose; split drops record after trailing ;
    attrs[:style].tr(' ', '').split(';').each do |style|
      pname, pvalue = style.split ':', 2
      case pname
      when 'color' # color needed to support syntax highlighters
        fragment[:color] = pvalue.length == 7 ? (pvalue.slice 1, 6) : (pvalue.slice 1, 3).each_char.map {|c| c * 2 }.join if (pvalue.start_with? '#') && (HexColorRx.match? pvalue)
      when 'font-weight'
        styles << :bold if pvalue == 'bold'
      when 'font-style'
        styles << :italic if pvalue == 'italic'
      when 'align', 'text-align'
        fragment[:align] = pvalue.to_sym
      when 'width'
        # NOTE: implicitly activates inline-block behavior
        fragment[:width] = pvalue
      when 'background-color' # background-color needed to support syntax highlighters
        if (pvalue.start_with? '#') && (HexColorRx.match? pvalue)
          fragment[:background_color] = pvalue.length == 7 ? (pvalue.slice 1, 6) : (pvalue.slice 1, 3).each_char.map {|c| c * 2 }.join
          # Q: is it possible that callback would already be set?
          #fragment[:callback] = [TextBackgroundAndBorderRenderer] | (fragment[:callback] || [])
          fragment[:callback] = [TextBackgroundAndBorderRenderer]
        end
      end
    end if attrs.key? :style
  end
  # TODO: we could limit to select tags, but doesn't seem to really affect performance
  attrs[:class].split.each do |class_name|
    fragment[:wj] = true if class_name == 'wj'
    next unless @theme_settings.key? class_name
    update_fragment fragment, @theme_settings[class_name]
    # NOTE: defer assignment of callback since we must look at combined styles of element and role
    if fragment[:background_color] || (fragment[:border_color] && fragment[:border_width])
      fragment[:callback] = [TextBackgroundAndBorderRenderer] | (fragment[:callback] || [])
      fragment[:align] = :center if fragment[:border_offset]
    end
  end if attrs.key? :class
  fragment.delete :styles if styles.empty?
  fragment[:callback] = (fragment[:callback] || []) | [InlineTextAligner] if fragment.key? :align
  fragment
end
clone_fragment(fragment, append = nil) click to toggle source
# File lib/asciidoctor/pdf/formatted_text/transform.rb, line 378
def clone_fragment fragment, append = nil
  if fragment
    fragment = fragment.dup
    fragment[:styles] = fragment[:styles].dup if fragment.key? :styles
    fragment[:callback] = fragment[:callback].drop 0 if fragment.key? :callback
  else
    fragment = {}
  end
  fragment.update append if append
  fragment
end
extract_text(pcdata) click to toggle source
# File lib/asciidoctor/pdf/formatted_text/transform.rb, line 426
def extract_text pcdata
  pcdata.reduce [] do |accum, it|
    case it[:type]
    when :text
      accum << it[:value]
    when :element
      accum += (extract_text it[:pcdata]) if it.key? :pcdata
    end
    accum
  end
end
restore_text(pcdata, text_chunks) click to toggle source
# File lib/asciidoctor/pdf/formatted_text/transform.rb, line 438
def restore_text pcdata, text_chunks
  pcdata.each do |it|
    case it[:type]
    when :text
      it[:value] = text_chunks.shift
    when :element
      restore_text it[:pcdata], text_chunks if it.key? :pcdata
    end
  end
end
to_styles(font_style, text_decoration = nil) click to toggle source
# File lib/asciidoctor/pdf/formatted_text/transform.rb, line 390
def to_styles font_style, text_decoration = nil
  case font_style
  when 'bold'
    styles = [:bold].to_set
  when 'italic'
    styles = [:italic].to_set
  when 'bold_italic'
    styles = [:bold, :italic].to_set
  when 'normal_italic'
    styles = [:normal, :italic].to_set
  end
  if (style = TextDecorationTable[text_decoration])
    styles ? (styles << style) : [style].to_set
  else
    styles
  end
end
update_fragment(fragment, props) click to toggle source
# File lib/asciidoctor/pdf/formatted_text/transform.rb, line 408
def update_fragment fragment, props
  fragment.update props do |k, oval, nval|
    case k
    when :styles
      if nval
        oval.subtract [:bold, :italic] if nval.delete? :normal
        oval.merge nval
      else
        oval.clear
      end
    #when :callback
    #  oval | nval
    else
      nval
    end
  end
end