lib/origami/javascript.rb



=begin

    This file is part of Origami, PDF manipulation framework for Ruby
    Copyright (C) 2016	Guillaume Delugré.

    Origami is free software: you can redistribute it and/or modify
    it under the terms of the GNU Lesser General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    Origami 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 Lesser General Public License for more details.

    You should have received a copy of the GNU Lesser General Public License
    along with Origami.  If not, see <http://www.gnu.org/licenses/>.

=end

module Origami

    begin
        require 'v8'

        class PDF

            module JavaScript
                module Platforms
                    WINDOWS = "WIN"
                    UNIX = "UNIX"
                    MAC = "MAC"
                end

                module Viewers
                    ADOBE_READER = "Reader"
                end

                class Error < Origami::Error; end

                class MissingArgError < Error
                    def initialize; super("Missing required argument.") end
                end

                class TypeError < Error
                    def initialize; super("Incorrect argument type.") end
                end

                class InvalidArgsError < Error
                    def initialize; super("Incorrect arguments.") end
                end

                class NotAllowedError < Error
                    def initialize; super("Security settings prevent access to this property or method.") end
                end

                class HelpError < Error
                    def initialize; super("Help") end
                end

                class GeneralError < Error
                    def initialize; super("Operation failed.") end
                end

                class Arg
                    attr_reader :name, :type, :required, :default

                    def initialize(declare = {})
                        @name = declare[:name]
                        @type = declare[:type]
                        @required = declare[:required]
                        @default = declare[:default]
                    end

                    def self.[](declare = {})
                        self.new(declare)
                    end

                    def self.inspect(obj)
                        case obj
                        when V8::Function then "function #{obj.name}"
                        when V8::Array then obj.to_a.inspect
                        when V8::Object
                            "{#{obj.to_a.map{|k,v| "#{k}:#{Arg.inspect(v)}"}.join(', ')}}"
                        else
                            obj.inspect
                        end
                    end
                end

                class AcrobatObject
                    def initialize(engine)
                        @engine = engine
                    end

                    def self.check_method_args(args, def_args)
                        if args.first.is_a?(V8::Object)
                            check_method_named_args(args.first, def_args)
                        else
                            check_method_ordered_args(args, def_args)
                        end
                    end

                    def self.check_method_named_args(object, def_args)
                        members = object.entries.map {|k, _| k}
                        argv = []
                        def_args.each do |def_arg|
                            raise MissingArgError if def_arg.required and not members.include?(def_arg.name)

                            if members.include?(def_arg.name)
                                arg = object[def_arg.name]
                                raise TypeError if def_arg.type and not arg.is_a?(def_arg.type)
                            else
                                arg = def_arg.default
                            end

                            argv.push(arg)
                        end

                        argv
                    end
                    private_class_method :check_method_named_args

                    def self.check_method_ordered_args(args, def_args)
                        def_args.each_with_index do |def_arg, index|
                            raise MissingArgError if def_arg.required and index >= args.length
                            raise TypeError if def_arg.type and not args[index].is_a?(def_arg.type)

                            args.push(def_arg.default) if index >= args.length
                        end

                        args
                    end
                    private_class_method :check_method_ordered_args

                    def self.acro_method(name, *def_args, &b)
                        define_method(name) do |*args|
                            if @engine.options[:log_method_calls]
                                @engine.options[:console].puts(
                                    "LOG: #{self.class}.#{name}(#{args.map{|arg| Arg.inspect(arg)}.join(',')})"
                                )
                            end

                            args = AcrobatObject.check_method_args(args, def_args)
                            self.instance_exec(*args, &b) if b
                        end
                    end

                    def self.acro_method_protected(name, *def_args, &b)
                        define_method(name) do |*args|
                            if @engine.options[:log_method_calls]
                                @engine.options[:console].puts(
                                    "LOG: #{self.class}.#{name}(#{args.map{|arg| arg.inspect}.join(',')})"
                                )
                            end

                            unless @engine.privileged?
                                raise NotAllowedError, "Security settings prevent access to this property or method."
                            end

                            args = AcrobatObject.check_method_args(args, def_args)
                            self.instance_exec(*args, &b) if b
                        end
                    end

                    def to_s
                        "[object #{self.class.to_s.split('::').last}]"
                    end
                    alias inspect to_s
                end

                class AcroTimer < AcrobatObject
                    def initialize(engine, timeout, code, repeat)
                        @thr = Thread.start(engine, timeout, code, repeat) do
                            loop do
                                sleep(timeout / 1000.0)
                                engine.exec(code.to_s)
                                break if not repeat
                            end
                        end
                    end
                end

                class TimeOut < AcroTimer
                    def initialize(engine, timeout, code)
                        super(engine, timeout, code, false)
                    end
                end

                class Interval < AcroTimer
                    def initialize(engine, timeout, code)
                        super(engine, timeout, code, true)
                    end
                end

                class ReadStream < AcrobatObject
                    def initialize(engine, data)
                        super(engine)

                        @data = data
                    end

                    acro_method 'read', Arg[name: 'nBytes', type: Numeric, required: true] do |nBytes|
                        @data.slice!(0, nBytes).unpack("H*")[0]
                    end
                end

                class Acrohelp < AcrobatObject; end

                class Global < AcrobatObject
                    def initialize(engine)
                        super(engine)

                        @vars = {}
                    end

                    def []=(name, value)
                        @vars[name] ||= {callbacks: []}
                        @vars[name][:value] = value
                        @vars[name][:callbacks].each do |callback|
                            callback.call(value)
                        end
                    end

                    def [](name)
                        @vars[name][:value] if @vars.include?(name)
                    end

                    acro_method 'setPersistent',
                        Arg[name: 'cVariable', required: true],
                        Arg[name: 'bPersist', required: true] do |cVariable, _bPersist|

                        raise GeneralError unless @vars.include?(cVariable)
                    end

                    acro_method 'subscribe',
                        Arg[name: 'cVariable', required: true],
                        Arg[name: 'fCallback', type: V8::Function, require: true] do |cVariable, fCallback|

                        if @vars.include?(cVariable)
                            @vars[cVariable][:callbacks].push(fCallback)
                            fCallback.call(@vars[cVariable][:value])
                        end
                    end
                end

                class Doc < AcrobatObject
                    attr_reader :info
                    attr_accessor :disclosed
                    attr_reader :hidden

                    attr_reader :app, :acrohelp, :global, :console, :util

                    class Info < AcrobatObject
                        def initialize(engine, doc)
                            super(engine)

                            @doc = doc
                        end

                        def title; @doc.title.to_s end
                        def author; @doc.author.to_s end
                        def subject; @doc.subject.to_s end
                        def keywords; @doc.keywords.to_s end
                        def creator; @doc.creator.to_s end
                        def creationDate; @doc.creation_date.to_s end
                        def modDate; @doc.mod_date.to_s end
                    end

                    def initialize(*args)
                        engine, pdf = args # XXX: Bypass therubyracer bug #238. Temporary.
                        super(engine)

                        @pdf = pdf
                        @disclosed = false
                        @hidden = false
                        @info = Info.new(@engine, pdf)

                        @app = JavaScript::App.new(@engine)
                        @acrohelp = JavaScript::Acrohelp.new(@engine)
                        @global = JavaScript::Global.new(@engine)
                        @console = JavaScript::Console.new(@engine)
                        @util = JavaScript::Util.new(@engine)
                    end

                    ### PROPERTIES ###

                    def numFields
                        fields = @pdf.fields

                        fields.size
                    end

                    def numPages; @pdf.pages.size end

                    def title; @info.title end
                    def author; @info.author end
                    def subject; @info.subject end
                    def keywords; @info.keywords end
                    def creator; @info.creator end
                    def creationDate; @info.creationDate end
                    def modDate; @info.modDate end

                    def metadata
                        meta = @pdf.Catalog.Metadata

                        (meta.data if meta.is_a?(Stream)).to_s
                    end

                    def filesize; @pdf.original_filesize end
                    def path; @pdf.original_filename.to_s end
                    def documentFileName; File.basename(self.path) end
                    def URL; "file://#{self.path}" end
                    def baseURL; '' end

                    def dataObjects
                        data_objs = []
                        @pdf.each_attachment do |name, file_desc|
                            if file_desc and file_desc.EF and (f = file_desc.EF.F)
                                data_objs.push Data.new(@engine, name, f.data.size) if f.is_a?(Stream)
                            end
                        end

                        data_objs
                    end

                    ### METHODS ###

                    acro_method 'closeDoc'

                    acro_method 'getDataObject',
                        Arg[name: 'cName', type: ::String, required: true] do |cName|

                        file_desc = @pdf.resolve_name(Names::EMBEDDED_FILES, cName)

                        if file_desc and file_desc.EF and (f = file_desc.EF.F)
                            Data.new(@engine, cName, f.data.size) if f.is_a?(Stream)
                        else
                            raise TypeError
                        end
                    end

                    acro_method 'getDataObjectContents',
                        Arg[name: 'cName', type: ::String, required: true],
                        Arg[name: 'bAllowAuth', default: false] do |cName, _bAllowAuth|

                        file_desc = @pdf.resolve_name(Names::EMBEDDED_FILES, cName)

                        if file_desc and file_desc.EF and (f = file_desc.EF.F)
                            ReadStream.new(@engine, f.data) if f.is_a?(Stream)
                        else
                            raise TypeError
                        end
                    end

                    acro_method 'exportDataObject',
                        Arg[name: 'cName', type: ::String, required: true],
                        Arg[name: 'cDIPath' ],
                        Arg[name: 'bAllowAuth'],
                        Arg[name: 'nLaunch'] do |cName, _cDIPath, _bAllowAuth, _nLaunch|

                        file_desc = @pdf.resolve_name(Names::EMBEDDED_FILES, cName)

                        if file_desc and file_desc.EF and (f = file_desc.EF.F)
                        else
                            raise TypeError
                        end

                        raise TypeError if f.nil?
                    end

                    acro_method 'getField',
                        Arg[name: 'cName', type: ::Object, required: true] do |cName|

                        field = @pdf.get_field(cName)

                        Field.new(@engine, field) if field
                    end

                    acro_method 'getNthFieldName',
                        Arg[name: 'nIndex', type: ::Object, required: true] do |nIndex|

                        nIndex =
                            case nIndex
                            when false then 0
                            when true then 1
                            else
                                @engine.parseInt.call(nIndex)
                            end

                        raise TypeError if (nIndex.is_a?(Float) and nIndex.nan?) or nIndex < 0
                        fields = @pdf.fields

                        if fields and nIndex <= fields.size - 1
                            Field.new(@engine, fields.take(nIndex + 1).last).name.to_s
                        else
                            ""
                        end
                    end
                end

                class App < AcrobatObject

                    def platform; @engine.options[:platform] end
                    def viewerType; @engine.options[:viewerType] end
                    def viewerVariation; @engine.options[:viewerVariation] end
                    def viewerVersion; @engine.options[:viewerVersion] end

                    def activeDocs; [] end

                    ### METHODS ###

                    acro_method 'setInterval',
                        Arg[name: 'cExpr', required: true],
                        Arg[name: 'nMilliseconds', type: Numeric, required: true] do |cExpr, nMilliseconds|

                        Interval.new(@engine, nMilliseconds, cExpr)
                    end

                    acro_method 'setTimeOut',
                        Arg[name: 'cExpr', required: true],
                        Arg[name: 'nMilliseconds', type: Numeric, required: true] do |cExpr, nMilliseconds|

                        TimeOut.new(@engine, nMilliseconds, cExpr)
                    end

                    acro_method 'clearInterval',
                        Arg[name: 'oInterval', type: Interval, required: true] do |oInterval|

                        oInterval.instance_variable_get(:@thr).terminate
                        nil
                    end

                    acro_method 'clearTimeOut',
                        Arg[name: 'oInterval', type: TimeOut, required: true] do |oInterval|

                        oInterval.instance_variable_get(:@thr).terminate
                        nil
                    end

                    acro_method_protected 'addMenuItem'
                    acro_method_protected 'addSubMenu'
                    acro_method           'addToolButton'
                    acro_method_protected 'beginPriv'
                    acro_method           'beep'
                    acro_method_protected 'browseForDoc'
                    acro_method_protected 'endPriv'
                end

                class Console < AcrobatObject
                    def println(*args)
                        raise MissingArgError unless args.length > 0

                        @engine.options[:console].puts(args.first.to_s)
                    end

                    acro_method 'show'
                    acro_method 'clear'
                    acro_method 'hide'
                end

                class Util < AcrobatObject
                    acro_method 'streamFromString',
                        Arg[name: 'cString', type: ::Object, required: true],
                        Arg[name: 'cCharset', type: ::Object, default: 'utf-8'] do |cString, _cCharset|

                        ReadStream.new(@engine, cString.to_s)
                    end

                    acro_method 'stringFromStream',
                        Arg[name: 'oStream', type: ReadStream, required: true],
                        Arg[name: 'cCharset', type: ::Object, default: 'utf-8'] do |oStream, _cCharset|

                        oStream.instance_variable_get(:@data).dup
                    end
                end

                class Field < AcrobatObject
                    def initialize(engine, field)
                        super(engine)

                        @field = field
                    end

                    def doc; Doc.new(@field.document) end
                    def name
                        (@field.T.value if @field.has_key?(:T)).to_s
                    end

                    def value
                        @field.V.value if @field.has_key?(:V)
                    end

                    def valueAsString
                        self.value.to_s
                    end

                    def type
                        return '' unless @field.key?(:FT)

                        type_name =
                        case @field.FT.value
                        when PDF::Field::Type::BUTTON
                            button_type

                        when PDF::Field::Type::TEXT then 'text'
                        when PDF::Field::Type::SIGNATURE then 'signature'
                        when PDF::Field::Type::CHOICE
                            choice_type
                        end

                        type_name.to_s
                    end

                    private

                    def button_type
                        return if @field.key?(:Ff) and not @field.Ff.is_a?(Integer)

                        flags = @field.Ff.to_i

                        if (flags & Annotation::Widget::Button::Flags::PUSHBUTTON) != 0
                            'button'
                        elsif (flags & Annotation::Widget::Button::Flags::RADIO) != 0
                            'radiobox'
                        else
                            'checkbox'
                        end
                    end

                    def choice_type
                        return if @field.key?(:Ff) and not @field.Ff.is_a?(Integer)

                        if (@field.Ff.to_i & Annotation::Widget::Choice::Flags::COMBO) != 0
                            'combobox'
                        else
                            'listbox'
                        end
                    end
                end

                class Data < AcrobatObject
                    attr_reader :name, :path, :size
                    attr_reader :creationDate, :modDate
                    attr_reader :description, :MIMEType

                    def initialize(engine, name, size, **metadata)
                        super(engine)

                        @name,  @size = name, size

                        @path, @creationDate, @modDate,
                        @description, @MIMEType = metadata.values_at(:path, :creationDate, :modDate, :description, :MIMEType)
                    end
                end
            end

            class JavaScript::EngineError < Origami::Error; end

            class JavaScript::Engine
                attr_reader :doc
                attr_reader :context
                attr_reader :options
                attr_reader :privileged_mode
                attr_reader :parseInt

                def initialize(pdf)
                    @options =
                    {
                        formsVersion: 11.008,
                        viewerVersion: 11.008,
                        viewerType: JavaScript::Viewers::ADOBE_READER,
                        viewerVariation: JavaScript::Viewers::ADOBE_READER,
                        platform: JavaScript::Platforms::WINDOWS,
                        console: STDOUT,
                        log_method_calls: false,
                        privileged_mode: false
                    }

                    @doc = JavaScript::Doc.new(self, pdf)
                    @context = V8::Context.new(with: @doc)
                    @privileged_mode = @options[:privileged_mode]

                    @parseInt = V8::Context.new['parseInt']
                    @hooks = {}
                end

                #
                # Returns true if the engine is set to execute in privileged mode.
                # Allows execution of security protected methods.
                #
                def privileged?
                    @privileged_mode
                end

                #
                # Evaluates a JavaScript code in the current context.
                #
                def exec(script)
                    @context.eval(script)
                end

                #
                # Set a hook on a JavaScript method.
                #
                def hook(name, &callback)
                    ns = name.split('.')
                    previous = @context

                    ns.each do |n|
                        raise JavaScript::EngineError, "#{name} does not exist" if previous.nil?
                        previous = previous[n]
                    end

                    case previous
                    when V8::Function, UnboundMethod, nil then
                        @context[name] = lambda do |*args|
                            callback[previous, *args]
                        end

                        @hooks[name] = [previous, callback]
                    else
                        raise JavaScript::EngineError, "#{name} is not a function"
                    end
                end

                #
                # Removes an existing hook on a JavaScript method.
                #
                def unhook(name)
                    @context[name] = @hooks[name][0] if @hooks.has_key?(name)
                end

                #
                # Returns an Hash of all defined members in specified object name.
                #
                def members(obj)
                    members = {}
                    list = @context.eval <<-JS
                        (function(base) {
                            var members = [];
                            for (var i in base) members.push([i, base[i]]);
                            return members;
                        })(#{obj})
                    JS

                    list.each do |var|
                        members[var[0]] = var[1]
                    end

                    members
                end

                #
                # Returns all members in the global scope.
                #
                def scope
                    members('this')
                end

                #
                # Binds the V8 remote debugging agent on the specified TCP _port_.
                #
                def enable_debugger(port = 5858)
                    V8::C::Debug.EnableAgent("Origami", port)
                end

                def debugger_break
                    exec 'debugger'
                end
            end
        end

        module String
            #
            # Evaluates the current String as JavaScript.
            #
            def eval_js
                self.document.eval_js(self.value)
            end
        end

        class Stream
            #
            # Evaluates the current Stream as JavaScript.
            #
            def eval_js
                self.document.eval_js(self.data)
            end
        end

        class PDF
            #
            # Executes a JavaScript script in the current document context.
            #
            def eval_js(code)
                js_engine.exec(code)
            end

            #
            # Returns the JavaScript engine (if JavaScript support is present).
            #
            def js_engine
                @js_engine ||= PDF::JavaScript::Engine.new(self)
            end
        end

    rescue LoadError
        #
        # V8 unavailable.
        #
    end
end