# -*- encoding: utf-8; frozen_string_literal: true -*-
#
#--
# This file is part of HexaPDF.
#
# HexaPDF - A Versatile PDF Creation and Manipulation Library For Ruby
# Copyright (C) 2014-2020 Thomas Leitner
#
# HexaPDF is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License version 3 as
# published by the Free Software Foundation with the addition of the
# following permission added to Section 15 as permitted in Section 7(a):
# FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY
# THOMAS LEITNER, THOMAS LEITNER DISCLAIMS THE WARRANTY OF NON
# INFRINGEMENT OF THIRD PARTY RIGHTS.
#
# HexaPDF is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
# License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with HexaPDF. If not, see .
#
# The interactive user interfaces in modified source and object code
# versions of HexaPDF must display Appropriate Legal Notices, as required
# under Section 5 of the GNU Affero General Public License version 3.
#
# In accordance with Section 7(b) of the GNU Affero General Public
# License, a covered work must retain the producer line in every PDF that
# is created or manipulated using HexaPDF.
#
# If the GNU Affero General Public License doesn't fit your need,
# commercial licenses are available at .
#++
require 'hexapdf/error'
require 'hexapdf/layout/style'
require 'hexapdf/layout/text_fragment'
module HexaPDF
module Type
module AcroForm
# The AppearanceGenerator class provides methods for generating and updating the appearance
# streams of form fields.
#
# The only method needed is #create_appearances since this method determines to what field the
# widget belongs and therefore which appearance should be generated.
#
# The visual appearance of a field is constructed using information from the field itself as
# well as information from the widget. See the documentation for the individual methods which
# information is used in which way.
#
# By default, any existing appearances are overwritten and the +:print+ flag is set on the
# widget so that the field appearance will appear on print-outs.
#
# The visual appearances are chosen to be similar to those used by Adobe Acrobat and others.
# By subclassing and overriding the necessary methods it is possible to define custom
# appearances.
#
# See: PDF1.7 s12.5.5, s12.7
class AppearanceGenerator
# Creates a new instance for the given +widget+.
def initialize(widget)
@widget = widget
@field = widget.form_field
@document = widget.document
end
# Creates the appropriate appearances for the widget.
def create_appearances
case @field.field_type
when :Btn
if @field.check_box?
create_check_box_appearances
elsif @field.radio_button?
create_radio_button_appearances
else
raise HexaPDF::Error, "Unsupported button field type"
end
when :Tx
create_text_appearances
when :Ch
if @field.combo_box?
create_text_appearances
else
raise HexaPDF::Error, "List box not supported yet"
end
else
raise HexaPDF::Error, "Unsupported field type #{@field.field_type}"
end
end
# Creates the appropriate appearances for check boxes.
#
# For unchecked boxes an empty rectangle is drawn. When checked, a symbol from the
# ZapfDingbats font is placed inside the rectangle. How this is exactly done depends on the
# following values:
#
# * The widget's rectangle /Rect must be defined. If the height and/or width of the
# rectangle are zero, they are based on the configuration option
# +acro_form.default_font_size+ and widget's border width. In such a case the rectangle is
# appropriately updated.
#
# * The line width, style and color of the rectangle are taken from the widget's border
# style. See HexaPDF::Type::Annotations::Widget#border_style.
#
# * The background color is determined by the widget's background color. See
# HexaPDF::Type::Annotations::Widget#background_color.
#
# * The symbol (marker) as well as its size and color are determined by the marker style of
# the widget. See HexaPDF::Type::Annotations::Widget#marker_style for details.
#
# Examples:
#
# widget.border_style(color: 0)
# widget.background_color(1)
# widget.marker_style(style: :check, size: 0, color: 0)
# # => default appearance
#
# widget.border_style(color: :transparent, width: 2)
# widget.background_color(0.7)
# widget.marker_style(style: :cross)
# # => no visible rectangle, gray background, cross mark when checked
def create_check_box_appearances
unless @widget.appearance&.normal_appearance&.value&.size == 2
raise HexaPDF::Error, "Widget of check box doesn't define name for on state"
end
border_style = @widget.border_style
border_width = border_style.width
rect = update_widget(@field[:V], border_width)
off_form = @widget.appearance.normal_appearance[:Off] =
@document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, rect.width, rect.height]})
apply_background_and_border(border_style, off_form.canvas)
on_form = @widget.appearance.normal_appearance[@field.check_box_on_name] =
@document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, rect.width, rect.height]})
canvas = on_form.canvas
apply_background_and_border(border_style, canvas)
canvas.save_graphics_state do
draw_marker(canvas, rect, border_width, @widget.marker_style)
end
end
# Creates the appropriate appearances for radio buttons.
#
# For unselected radio buttons an empty circle (if the marker is :circle) or rectangle is
# drawn inside the widget annotation's rectangle. When selected, a symbol from the
# ZapfDingbats font is placed inside. How this is exactly done depends on the following
# values:
#
# * The widget's rectangle /Rect must be defined. If the height and/or width of the
# rectangle are zero, they are based on the configuration option
# +acro_form.default_font_size+ and the widget's border width. In such a case the
# rectangle is appropriately updated.
#
# * The line width, style and color of the circle/rectangle are taken from the widget's
# border style. See HexaPDF::Type::Annotations::Widget#border_style.
#
# * The background color is determined by the widget's background color. See
# HexaPDF::Type::Annotations::Widget#background_color.
#
# * The symbol (marker) as well as its size and color are determined by the marker style of
# the widget. See HexaPDF::Type::Annotations::Widget#marker_style for details.
#
# Examples:
#
# widget.border_style(color: 0)
# widget.background_color(1)
# widget.marker_style(style: :circle, size: 0, color: 0)
# # => default appearance
def create_radio_button_appearances
unless @widget.appearance&.normal_appearance&.value&.size == 2
raise HexaPDF::Error, "Widget of radio button doesn't define unique name for on state"
end
on_name = (@widget.appearance.normal_appearance.value.keys - [:Off]).first
border_style = @widget.border_style
marker_style = @widget.marker_style
rect = update_widget(@field[:V] == on_name ? on_name : :Off, border_style.width)
off_form = @widget.appearance.normal_appearance[:Off] =
@document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, rect.width, rect.height]})
apply_background_and_border(border_style, off_form.canvas,
circular: marker_style.style == :circle)
on_form = @widget.appearance.normal_appearance[on_name] =
@document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, rect.width, rect.height]})
canvas = on_form.canvas
apply_background_and_border(border_style, canvas,
circular: marker_style.style == :circle)
canvas.save_graphics_state do
draw_marker(canvas, rect, border_style.width, @widget.marker_style)
end
end
# Creates the appropriate appearances for text fields.
#
# The following describes how the appearance is built:
#
# * The font, font size and font color are taken from the associated field's default
# appearance string. See VariableTextField.
#
# * The widget's rectangle /Rect must be defined. If the height is zero, it is auto-sized
# based on the font size. If additionally the font size is zero, a font size of
# +acro_form.default_font_size+ is used. If the width is zero, the
# +acro_form.text_field.default_width+ value is used. In such cases the rectangle is
# appropriately updated.
#
# * The line width, style and color of the rectangle are taken from the widget's border
# style. See HexaPDF::Type::Annotations::Widget#border_style.
#
# * The background color is determined by the widget's background color. See
# HexaPDF::Type::Annotations::Widget#background_color.
#
# Note: Multiline, comb and rich text fields are currently not supported!
def create_text_appearances
font_name, font_size = @field.parse_default_appearance_string
default_resources = @document.acro_form.default_resources
font = default_resources.font(font_name).font_wrapper
unless font
fallback_font_name, fallback_font_options = @document.config['acro_form.fallback_font']
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
style = HexaPDF::Layout::Style.new(font: font)
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
form = (@widget[:AP] ||= {})[:N] = @document.add({Type: :XObject, Subtype: :Form,
BBox: [0, 0, rect.width, rect.height]})
form[:Resources] = HexaPDF::Object.deep_copy(default_resources)
canvas = form.canvas
apply_background_and_border(border_style, canvas)
style.font_size = calculate_font_size(font, font_size, rect, border_style)
canvas.marked_content_sequence(:Tx) do
if (value = @field.field_value)
canvas.save_graphics_state do
canvas.rectangle(padding, padding, rect.width - 2 * padding,
rect.height - 2 * padding).clip_path.end_path
fragment = HexaPDF::Layout::TextFragment.create(value, style)
# Adobe seems to be left/right-aligning based on twice the border width and
# vertically centering based on the cap height, if enough space is available
x = case @field.text_alignment
when :left then 2 * padding
when :right then [rect.width - 2 * padding - fragment.width, 2 * padding].max
when :center then [(rect.width - fragment.width) / 2.0, 2 * padding].max
end
cap_height = font.wrapped_font.cap_height * font.scaling_factor / 1000.0 *
style.font_size
y = padding + (rect.height - 2 * padding - cap_height) / 2.0
y = padding - style.scaled_font_descender if y < 0
fragment.draw(canvas, x, y)
end
end
end
end
alias create_combo_box_appearances create_text_appearances
private
# Updates the widget and returns its (possibly modified) rectangle.
#
# The following changes are made:
#
# * Sets the appearance state to +appearance_state+.
# * Sets the :print flag.
# * Adjusts the rectangle based on the default font size and the given border width if its
# width and/or height are zero.
def update_widget(appearance_state, border_width)
@widget[:AS] = appearance_state
@widget.flag(:print)
default_font_size = @document.config['acro_form.default_font_size']
rect = @widget[:Rect]
rect.width = default_font_size + 2 * border_width if rect.width == 0
rect.height = default_font_size + 2 * border_width if rect.height == 0
rect
end
# Applies the background and border style of the widget annotation to the appearances.
#
# If +circular+ is +true+, then the border is drawn as inscribed circle instead of as
# rectangle.
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)
end
end
canvas.stroke
end
canvas.restore_graphics_state
end
end
# Draws the marker defined by the marker style inside the widget's rectangle.
#
# This method can only used for check boxes and radio buttons!
def draw_marker(canvas, rect, border_width, marker_style)
if @field.radio_button? && marker_style.style == :circle
# Acrobat handles this specially
canvas.
fill_color(marker_style.color).
circle(rect.width / 2.0, rect.height / 2.0,
([rect.width / 2.0, rect.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, rect.width - border_width,
rect.height - border_width).
line(border_width, rect.height - border_width, rect.width - border_width,
border_width).
stroke
else
font = @document.fonts.add('ZapfDingbats')
mark = font.decode_utf8(@widget[:MK]&.[](:CA) || '4').first
square_width = [rect.width, rect.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 = (rect.width - square_width) / 2.0 + (square_width - mark_width) / 2.0
y_offset = (rect.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
# Calculates the font size for text fields based on the font and font size of the default
# appearance string, the annotation rectangle and the border style.
def calculate_font_size(font, font_size, rect, border_style)
if font_size == 0
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
(rect.height - 2 * border_style.width) / unit_font_size * 0.83
else
font_size
end
end
end
end
end
end