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://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)
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)
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)
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
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.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
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
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)
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)
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)
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)
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)
def initialize(widget) @widget = widget @field = widget.form_field @document = widget.document end
def perform_rotation(width, height)
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)
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