# -*- 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/cli/command' require 'strscan' module HexaPDF module CLI # Processes a PDF that contains an interactive form (AcroForm). class Form < Command def initialize #:nodoc: super('form', takes_commands: false) short_desc("Show form fields and fill out a form") long_desc(<<~EOF) Use this command to process interactive PDF forms. If the the output file name is not given, all form fields are listed in page order. Use the global --verbose option to show additional information like field type and location. If the output file name is given, the fields can be interactively filled out. By additionally using the --template option, the data for the fields is read from the given template file instead of the standard input. EOF options.on("--password PASSWORD", "-p", String, "The password for decryption. Use - for reading from standard input.") do |pwd| @password = (pwd == '-' ? read_password : pwd) end options.on("--template TEMPLATE_FILE", "-t TEMPLATE_FILE", "Use the template file for the field values") do |template| @template = template end options.on("--[no-]viewer-override", "Let the PDF viewer override the visual " \ "appearance. Default: use setting from input PDF") do |need_appearances| @need_appearances = need_appearances end options.on("--[no-]incremental-save", "Append the changes instead of rewriting the " \ "whole file. Default: true") do |incremental| @incremental = incremental end @password = nil @template = nil @need_appearances = nil @incremental = true end def execute(in_file, out_file = nil) #:nodoc: maybe_raise_on_existing_file(out_file) if out_file with_document(in_file, password: @password, out_file: out_file, incremental: @incremental) do |doc| if !doc.acro_form raise "This PDF doesn't contain an interactive form" elsif out_file doc.acro_form[:NeedAppearances] = @need_appearances unless @need_appearances.nil? if @template fill_form_with_template(doc) else fill_form(doc) end else list_form_fields(doc) end end end private # Lists all terminal form fields. def list_form_fields(doc) current_page_index = -1 each_field(doc) do |_page, page_index, field, widget| if current_page_index != page_index puts "Page #{page_index + 1}" current_page_index = page_index end field_name = field.full_field_name + (field.alternate_field_name ? " (#{field.alternate_field_name})" : '') concrete_field_type = field.concrete_field_type nice_field_type = concrete_field_type.to_s.split('_').map(&:capitalize).join(' ') position = "(#{widget[:Rect].left}, #{widget[:Rect].bottom})" puts " #{field_name}" if command_parser.verbosity_info? printf(" └─ %-22s | %-20s\n", nice_field_type, position) end puts " └─ #{field.field_value.inspect}" if command_parser.verbosity_info? if field.field_type == :Ch puts " └─ Options: #{field.option_items.map(&:inspect).join(', ')}" elsif concrete_field_type == :radio_button puts " └─ Options: #{field.radio_button_values.map(&:inspect).join(', ')}" end end end end # Fills out the form by interactively asking the user for field values. def fill_form(doc) current_page_index = -1 each_field(doc) do |_page, page_index, field, _widget| if current_page_index != page_index puts "Page #{page_index + 1}" current_page_index = page_index end field_name = field.full_field_name + (field.alternate_field_name ? " (#{field.alternate_field_name})" : '') concrete_field_type = field.concrete_field_type puts " #{field_name}" puts " └─ Current value: #{field.field_value.inspect}" if field.field_type == :Ch puts " └─ Possible values: #{field.option_items.map(&:inspect).join(', ')}" elsif concrete_field_type == :radio_button puts " └─ Possible values: #{field.radio_button_values.map(&:inspect).join(', ')}" elsif concrete_field_type == :check_box puts " └─ Possible values: y(es), t(rue); n(o), f(alse)" end begin print " └─ New value: " value = $stdin.readline.chomp next if value.empty? apply_field_value(field, value) rescue HexaPDF::Error => e puts " ⚠ #{e.message}" retry end end end # Fills out the form using the data from the provided template file. def fill_form_with_template(doc) data = parse_template form = doc.acro_form data.each do |name, value| field = form.field_by_name(name) raise "Field '#{name}' not found in input PDF" unless field apply_field_value(field, value) end end # Parses the data from the given template file. def parse_template data = {} scanner = StringScanner.new(File.read(@template)) until scanner.eos? field_name = scanner.scan(/(\\:|[^:])*?:/) break unless field_name field_name.gsub!(/\\:/, ':') field_value = scanner.scan(/.*?(?=^\S|\z)/m) data[field_name.chop] = field_value.strip.gsub(/^\s*/, '') if field_value end if !scanner.eos? && command_parser.verbosity_warning? $stderr.puts "Warning: Some template could not be parsed" end data end # Applies the given value to the field. def apply_field_value(field, value) case field.concrete_field_type when :single_line_text_field field.field_value = value when :combo_box, :list_box field.field_value = value when :editable_combo_box field.field_value = value when :check_box unless value.match?(/y(es)?|t(rue)?|f(alse)?|n(o)/) raise HexaPDF::Error, "Invalid input, use one of the possible values" end field.field_value = value.match?(/y(es)?|t(rue)?/) when :radio_button field.field_value = value.intern else raise "Field type not yet supported" end end # Iterates over all non-push button fields in page order. If a field appears on multiple # pages, it is only yielded on the first page. def each_field(doc) # :yields: page, page_index, field seen = {} doc.pages.each_with_index do |page, page_index| page[:Annots]&.each do |annotation| next unless annotation[:Subtype] == :Widget field = annotation.form_field next if field.concrete_field_type == :push_button unless seen[field] yield(page, page_index, field, annotation) seen[field] = true end end end end end end end