class HexaPDF::Type::AcroForm::AppearanceGenerator

See: PDF2.0 s12.5.5, s12.7
appearances.
By subclassing and overriding the necessary methods it is possible to define custom
The visual appearances are chosen to be similar to those used by Adobe Acrobat and others.
widget so that the field appearance will appear on print-outs.
By default, any existing appearances are overwritten and the :print flag is set on the
information is used in which way.
well as information from the widget. See the documentation for the individual methods which
The visual appearance of a field is constructed using information from the field itself as
widget belongs and therefore which appearance should be generated.
The only method needed is #create_appearances since this method determines to what field the
streams of form fields.
The AppearanceGenerator class provides methods for generating and updating the appearance

def apply_af_number_format(value, match)

- https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsapiref/JS_API_AcroJS.html#printf
- https://experienceleague.adobe.com/docs/experience-manager-learn/assets/FormsAPIReference.pdf
See:

Implements the Javascript AFNumber_Format method.
def apply_af_number_format(value, match)
  value = value.to_f
  format = "%.#{match[:ndec]}f"
  text_color = 'black'
  currency_string = JSON.parse(match[:currency_string])
  format = (match[:prepend] == 'true' ? currency_string + format : format + currency_string)
  if value < 0
    value = value.abs
    case match[:neg_style]
    when '0' # MinusBlack
      format = "-#{format}"
    when '1' # Red
      text_color = 'red'
    when '2' # ParensBlack
      format = "(#{format})"
    when '3' # ParensRed
      format = "(#{format})"
      text_color = 'red'
    end
  end
  result = sprintf(format, value)
  # sep_style: 0=12,345.67, 1=12345.67, 2=12.345,67, 3=12345,67
  before_decimal_point, after_decimal_point = result.split('.')
  if match[:sep_style] == '0' || match[:sep_style] == '2'
    separator = (match[:sep_style] == '0' ? ',' : '.')
    before_decimal_point.gsub!(/\B(?=(\d\d\d)+(?:[^\d]|\z))/, separator)
  end
  result = if after_decimal_point
             decimal_point = (match[:sep_style] =~ /[01]/ ? '.' : ',')
             "#{before_decimal_point}#{decimal_point}#{after_decimal_point}"
           else
             before_decimal_point
           end
  [result, text_color]
end

def apply_background_and_border(border_style, canvas, circular: false)

rectangle.
If +circular+ is +true+, then the border is drawn as inscribed circle instead of as

Applies the background and border style of the widget annotation to the appearances.
def apply_background_and_border(border_style, canvas, circular: false)
  rect = @widget[:Rect]
  background_color = @widget.background_color
  if (border_style.width > 0 && border_style.color) || background_color
    canvas.save_graphics_state
    if background_color
      canvas.fill_color(background_color)
      if circular
        canvas.circle(rect.width / 2.0, rect.height / 2.0,
                      [rect.width / 2.0, rect.height / 2.0].min)
      else
        canvas.rectangle(0, 0, rect.width, rect.height)
      end
      canvas.fill
    end
    if border_style.color
      offset = [0.5, border_style.width / 2.0].max
      width, height = rect.width - 2 * offset, rect.height - 2 * offset
      canvas.stroke_color(border_style.color).line_width(border_style.width)
      if border_style.style == :underlined # TODO: :beveleded, :inset
        if circular
          canvas.arc(rect.width / 2.0, rect.height / 2.0,
                     a: [width / 2.0, height / 2.0].min,
                     start_angle: 180, end_angle: 0)
        else
          canvas.line(offset, offset, offset + width, offset)
        end
      else
        canvas.line_dash_pattern(border_style.style) if border_style.style.kind_of?(Array)
        if circular
          canvas.circle(rect.width / 2.0, rect.height / 2.0, [width / 2.0, height / 2.0].min)
        else
          canvas.rectangle(offset, offset, width, height)
          if @field.concrete_field_type == :comb_text_field
            cell_width = rect.width.to_f / @field[:MaxLen]
            1.upto(@field[:MaxLen] - 1) do |i|
              canvas.line(i * cell_width, border_style.width,
                          i * cell_width, border_style.width + height)
            end
          end
        end
      end
      canvas.stroke
    end
    canvas.restore_graphics_state
  end
end

def apply_javascript_formatting(value)

text value.
value and the second argument is either +nil+ or the color that should be used for the
Returns [value, nil_or_text_color] where value is the new, potentially adjusted field

Handles Javascript formatting routines for single-line text fields.
def apply_javascript_formatting(value)
  format_action = @widget[:AA]&.[](:F)
  return [value, nil] unless format_action && format_action[:S] == :JavaScript
  if (match = AF_NUMBER_FORMAT_RE.match(format_action[:JS]))
    apply_af_number_format(value, match)
  else
    [value, nil]
  end
end

def calculate_and_apply_font_size(value, style, width, height, padding)

width and the given padding. The font size is then applied to the provided style object.
and font size of the default appearance string, the annotation rectangle's height and
Calculates the font size for single line text fields using auto-sizing, based on the font
def calculate_and_apply_font_size(value, style, width, height, padding)
  return if style.font_size != 0
  font = style.font
  unit_font_size = (font.wrapped_font.bounding_box[3] - font.wrapped_font.bounding_box[1]) *
    font.scaling_factor / 1000.0
  # The constant factor was found empirically by checking what Adobe Reader etc. do
  style.font_size = (height - 2 * padding) / unit_font_size * 0.85
  fragment = HexaPDF::Layout::TextFragment.create(value, style)
  style.font_size = [style.font_size, style.font_size * (width - 4 * padding) / fragment.width].min
  style.clear_cache
end

def create_appearances

Creates the appropriate appearances for the widget.
def create_appearances
  case @field.field_type
  when :Btn
    if @field.push_button?
      create_push_button_appearances
    else
      create_check_box_appearances
    end
  when :Tx, :Ch
    create_text_appearances
  else
    raise HexaPDF::Error, "Unsupported field type #{@field.field_type}"
  end
end

def create_check_box_appearances

widget.marker_style(style: :circle, size: 0, color: 0)
widget.background_color(1)
widget.border_style(color: 0)
# radio button: default appearance

widget.marker_style(style: :cross)
widget.background_color(0.7)
widget.border_style(color: :transparent, width: 2)
# check box: no visible rectangle, gray background, cross mark when checked

widget.marker_style(style: :check, size: 0, color: 0)
widget.background_color(1)
widget.border_style(color: 0)
# check box: default appearance

Examples:

the widget. See HexaPDF::Type::Annotations::Widget#marker_style for details.
* The symbol (marker) as well as its size and color are determined by the marker style of

HexaPDF::Type::Annotations::Widget#background_color.
* The background color is determined by the widget's background color. See

border style. See HexaPDF::Type::Annotations::Widget#border_style.
* The line width, style and color of the cirle/rectangle are taken from the widget's

appropriately updated.
+acro_form.default_font_size+ and widget's border width. In such a case the rectangle is
rectangle are zero, they are based on the configuration option
* The widget's rectangle /Rect must be defined. If the height and/or width of the

depends on the following values:
selected, a symbol from the ZapfDingbats font is placed inside. How this is exactly done
an empty circle (if the marker is :circle) or rectangle is drawn. When checked or
For unchecked boxes an empty rectangle is drawn. Similarly, for unselected radio buttons

used for the appearance of the checked box or selected radio button.
the key /Off. If there is more than one other key besides the /Off key, the first one is
The unchecked box or unselected radio button is always represented by the appearance with

Creates the appropriate appearances for check boxes and radio buttons.
def create_check_box_appearances
  appearance_keys = @widget.appearance_dict&.normal_appearance&.value&.keys || []
  on_name = (appearance_keys - [:Off]).first
  unless on_name
    raise HexaPDF::Error, "Widget of button field doesn't define name for on state"
  end
  @widget[:AS] = (@field[:V] == on_name ? on_name : :Off)
  @widget.flag(:print)
  border_style = @widget.border_style
  marker_style = @widget.marker_style
  circular = (@field.radio_button? && marker_style.style == :circle)
  default_font_size = @document.config['acro_form.default_font_size']
  rect = @widget[:Rect]
  rect.width = default_font_size + 2 * border_style.width if rect.width == 0
  rect.height = default_font_size + 2 * border_style.width if rect.height == 0
  width, height, matrix = perform_rotation(rect.width, rect.height)
  off_form = @widget.appearance_dict.normal_appearance[:Off] =
    @document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, width, height],
                   Matrix: matrix})
  apply_background_and_border(border_style, off_form.canvas, circular: circular)
  on_form = @widget.appearance_dict.normal_appearance[on_name] =
    @document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, width, height],
                   Matrix: matrix})
  canvas = on_form.canvas
  apply_background_and_border(border_style, canvas, circular: circular)
  canvas.save_graphics_state do
    draw_marker(canvas, width, height, border_style.width, marker_style)
  end
end

def create_push_button_appearances

This is currently a dummy implementation raising an error.

Creates the appropriate appearances for push buttons.
def create_push_button_appearances
  raise HexaPDF::Error, "Push button appearance generation not yet supported"
end

def create_text_appearances

Note: Rich text fields are currently not supported!

HexaPDF::Type::Annotations::Widget#background_color.
* The background color is determined by the widget's background color. See

style. See HexaPDF::Type::Annotations::Widget#border_style.
* The line width, style and color of the rectangle are taken from the widget's border

appropriately updated.
+acro_form.text_field.default_width+ value is used. In such cases the rectangle is
+acro_form.default_font_size+ is used. If the width is zero, the
based on the font size. If additionally the font size is zero, a font size of
* The widget's rectangle /Rect must be defined. If the height is zero, it is auto-sized

configuration option +acro_form.fallback_font+ will be used.
associated information in the form's default resources), the font specified by the
If the font is not usable by HexaPDF (which may be due to a variety of reasons, e.g. no

appearance string. See VariableTextField.
* The font, font size and font color are taken from the associated field's default

The following describes how the appearance is built:

Creates the appropriate appearances for text fields, combo box fields and list box fields.
def create_text_appearances
  default_resources = @document.acro_form.default_resources
  font, font_size, font_color = retrieve_font_information(default_resources)
  style = HexaPDF::Layout::Style.new(font: font, font_size: font_size, fill_color: font_color)
  border_style = @widget.border_style
  padding = [1, border_style.width].max
  @widget[:AS] = :N
  @widget.flag(:print)
  rect = @widget[:Rect]
  rect.width = @document.config['acro_form.text_field.default_width'] if rect.width == 0
  if rect.height == 0
    style.font_size = \
      (font_size == 0 ? @document.config['acro_form.default_font_size'] : font_size)
    rect.height = style.scaled_y_max - style.scaled_y_min + 2 * padding
  end
  width, height, matrix = perform_rotation(rect.width, rect.height)
  form = (@widget[:AP] ||= {})[:N] ||= @document.add({Type: :XObject, Subtype: :Form})
  # Wrap existing object in Form class in case the PDF writer didn't include the /Subtype
  # key; we can do this since we know this has to be a Form object
  form = @document.wrap(form, type: :XObject, subtype: :Form) unless form[:Subtype] == :Form
  form.value.replace({Type: :XObject, Subtype: :Form, BBox: [0, 0, width, height],
                      Matrix: matrix, Resources: HexaPDF::Object.deep_copy(default_resources)})
  form.contents = ''
  canvas = form.canvas
  apply_background_and_border(border_style, canvas)
  canvas.marked_content_sequence(:Tx) do
    if @field.field_value || @field.concrete_field_type == :list_box
      canvas.save_graphics_state do
        canvas.rectangle(padding, padding, width - 2 * padding,
                         height - 2 * padding).clip_path.end_path
        case @field.concrete_field_type
        when :multiline_text_field
          draw_multiline_text(canvas, width, height, style, padding)
        when :list_box
          draw_list_box(canvas, width, height, style, padding)
        else
          draw_single_line_text(canvas, width, height, style, padding)
        end
      end
    end
  end
end

def draw_list_box(canvas, width, height, style, padding)

Draws the visible option items of the list box in the widget's rectangle.
def draw_list_box(canvas, width, height, style, padding)
  if style.font_size == 0
    style.font_size = 12 # Seems to be Adobe's default
    style.clear_cache
  end
  option_items = @field.option_items
  top_index = @field.list_box_top_index
  items = [Layout::TextFragment.create(option_items[top_index..-1].join("\n"), style)]
  # Should use /I but if it differs from /V, we need to use /V; so just use /V...
  indices = [@field.field_value].flatten.compact.map {|val| option_items.index(val) }
  layouter = Layout::TextLayouter.new(style)
  layouter.style.align(@field.text_alignment).line_spacing(:proportional, 1.25)
  result = layouter.fit(items, width - 4 * padding, height)
  unless result.lines.empty?
    top_gap = style.line_spacing.gap(result.lines[0], result.lines[0])
    line_height = style.line_spacing.baseline_distance(result.lines[0], result.lines[0])
    canvas.fill_color(153, 193, 218) # Adobe's color for selection highlighting
    indices.map! {|i| height - padding - (i - top_index + 1) * line_height }.each do |y|
      next if y + line_height > height || y + line_height < padding
      canvas.rectangle(padding, y, width - 2 * padding, line_height)
    end
    canvas.fill if canvas.graphics_object == :path
    result.draw(canvas, 2 * padding, height - padding - top_gap)
  end
end

def draw_marker(canvas, width, height, border_width, marker_style)

This method can only used for check boxes and radio buttons!

Draws the marker defined by the marker style inside the widget's rectangle.
def draw_marker(canvas, width, height, border_width, marker_style)
  if @field.radio_button? && marker_style.style == :circle
    # Acrobat handles this specially
    canvas.
      fill_color(marker_style.color).
      circle(width / 2.0, height / 2.0,
             ([width / 2.0, height / 2.0].min - border_width) / 2).
      fill
  elsif marker_style.style == :cross # Acrobat just places a cross inside
    canvas.
      stroke_color(marker_style.color).
      line(border_width, border_width, width - border_width,
           height - border_width).
      line(border_width, height - border_width, width - border_width,
           border_width).
      stroke
  else
    font = @document.fonts.add('ZapfDingbats')
    marker_string = @widget[:MK]&.[](:CA).to_s
    mark = font.decode_utf8(marker_string.empty? ? '4' : marker_string).first
    square_width = [width, height].min - 2 * border_width
    font_size = (marker_style.size == 0 ? square_width : marker_style.size)
    mark_width = mark.width * font.scaling_factor * font_size / 1000.0
    mark_height = (mark.y_max - mark.y_min) * font.scaling_factor * font_size / 1000.0
    x_offset = (width - square_width) / 2.0 + (square_width - mark_width) / 2.0
    y_offset = (height - square_width) / 2.0 + (square_width - mark_height) / 2.0 -
      (mark.y_min * font.scaling_factor * font_size / 1000.0)
    canvas.font(font, size: font_size)
    canvas.fill_color(marker_style.color)
    canvas.move_text_cursor(offset: [x_offset, y_offset]).show_glyphs_only([mark])
  end
end

def draw_multiline_text(canvas, width, height, style, padding)

Draws multiple lines of text inside the widget's rectangle.
def draw_multiline_text(canvas, width, height, style, padding)
  items = [Layout::TextFragment.create(@field.field_value, style)]
  layouter = Layout::TextLayouter.new(style)
  layouter.style.align(@field.text_alignment).line_spacing(:proportional, 1.25)
  result = nil
  if style.font_size == 0 # need to auto-size text
    style.font_size = 12 # Adobe seems to use this as starting point
    style.clear_cache
    loop do
      result = layouter.fit(items, width - 4 * padding, height - 4 * padding)
      break if result.status == :success || style.font_size <= 4 # don't make text too small
      style.font_size -= 1
      style.clear_cache
    end
  else
    result = layouter.fit(items, width - 4 * padding, 2**20)
  end
  unless result.lines.empty?
    result.draw(canvas, 2 * padding, height - 2 * padding - result.lines[0].height / 2.0)
  end
end

def draw_single_line_text(canvas, width, height, style, padding)

Draws a single line of text inside the widget's rectangle.
def draw_single_line_text(canvas, width, height, style, padding)
  value, text_color = apply_javascript_formatting(@field.field_value)
  style.fill_color = text_color if text_color
  calculate_and_apply_font_size(value, style, width, height, padding)
  fragment = HexaPDF::Layout::TextFragment.create(value, style)
  if @field.concrete_field_type == :comb_text_field
    unless @field.key?(:MaxLen)
      raise HexaPDF::Error, "Missing or invalid dictionary field /MaxLen for comb text field"
    end
    new_items = []
    cell_width = width.to_f / @field[:MaxLen]
    scaled_cell_width = cell_width / style.scaled_font_size.to_f
    fragment.items.each_cons(2) do |a, b|
      new_items << a << -(scaled_cell_width - a.width / 2.0 - b.width / 2.0)
    end
    new_items << fragment.items.last
    fragment.items.replace(new_items)
    fragment.clear_cache
    # Adobe always seems to add 1 to the first offset...
    x_offset = 1 + (cell_width - style.scaled_item_width(fragment.items[0])) / 2.0
    x = case @field.text_alignment
        when :left then x_offset
        when :right then x_offset + cell_width * (@field[:MaxLen] - value.length)
        when :center then x_offset + cell_width * ((@field[:MaxLen] - value.length) / 2)
        end
  else
    # Adobe seems to be left/right-aligning based on twice the border width
    x = case @field.text_alignment
        when :left then 2 * padding
        when :right then [width - 2 * padding - fragment.width, 2 * padding].max
        when :center then [(width - fragment.width) / 2.0, 2 * padding].max
        end
  end
  # Adobe seems to be vertically centering based on the cap height, if enough space is
  # available
  tmp_cap_height = style.font.wrapped_font.cap_height ||
    style.font.pdf_object.font_descriptor&.[](:CapHeight)
  cap_height = tmp_cap_height * style.font.scaling_factor / 1000.0 *
    style.font_size
  y = padding + (height - 2 * padding - cap_height) / 2.0
  y = padding - style.scaled_font_descender if y < 0
  fragment.draw(canvas, x, y)
end

def initialize(widget)

Creates a new instance for the given +widget+.
def initialize(widget)
  @widget = widget
  @field = widget.form_field
  @document = widget.document
end

def perform_rotation(width, height)

returns the correct width, height and Form XObject matrix.
Performs the rotation specified in /R of the appearance characteristics dictionary and
def perform_rotation(width, height)
  matrix = case (@widget[:MK]&.[](:R) || 0) % 360
           when 90
             width, height = height, width
             [0, 1, -1, 0, 0, 0]
           when 270
             width, height = height, width
             [0, -1, 1, 0, 0, 0]
           when 180
             [0, -1, -1, 0, 0, 0]
           end
  [width, height, matrix]
end

def retrieve_font_information(resources)

Returns the font wrapper and font size to be used for a variable text field.
def retrieve_font_information(resources)
  font_name, font_size, font_color = @field.parse_default_appearance_string
  font_object = resources.font(font_name) rescue nil
  font = font_object&.font_wrapper
  unless font
    fallback_font = @document.config['acro_form.fallback_font']
    fallback_font_name, fallback_font_options = if fallback_font.respond_to?(:call)
                                                  fallback_font.call(@field, font_object)
                                                else
                                                  fallback_font
                                                end
    if fallback_font_name
      font = @document.fonts.add(fallback_font_name, **(fallback_font_options || {}))
    else
      raise(HexaPDF::Error, "Font #{font_name} of the AcroForm's default resources not usable")
    end
  end
  [font, font_size, font_color]
end