=begin

= File
	javascript.rb

= Info
	This file is part of Origami, PDF manipulation framework for Ruby
	Copyright (C) 2010	Guillaume Delugré <guillaume AT security-labs DOT org>
	All right reserved.
	  
  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 V8::Object
      #def inspect
      #  case self
      #    when V8::Array,V8::Function then super
      #    else
      #      "{#{self.to_a.map{|k,v| "#{k}:#{v.inspect}"}.join(', ')}}"
      #  end
      #end
    end

    class PDF
  
      module JavaScript

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

        module Viewers
          ADOBE_READER = "Reader"
        end

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

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

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

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

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

        class GeneralError < Exception
          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)
              args = args.first
              members = args.entries.map{|k,v| 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 = args[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

              args = argv
            else
              i = 0
              def_args.each do |def_arg|
                raise MissingArgError if def_arg.required and i >= args.length
                raise TypeError if def_arg.type and not args[i].is_a?(def_arg.type)

                args.push(def_arg.default) if i >= args.length

                i = i + 1
              end
            end

            args
          end
          
          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

              raise NotAllowedError

              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)
                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, :required => 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

          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)
          end

          ### PROPERTIES ###

          def numFields
            fields = @pdf.fields
            if fields.nil?
              0
            else
              fields.size
            end
          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.ls_names(Names::Root::EMBEDDEDFILES).each 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::Root::EMBEDDEDFILES, 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::Root::EMBEDDEDFILES, 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::Root::EMBEDDEDFILES, 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

              (Field.new(@engine, fields[nIndex]).name if fields and fields[nIndex]).to_s
            end
        end

        class App < AcrobatObject
          attr_reader :platform, :viewerVariation, :viewerVersion

          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|
              cExpr = cExpr.is_a?(::String) ? cExpr : ''
              Interval.new(@engine, nMilliseconds, cExpr)
            end

          acro_method 'setTimeOut',
            Arg[:name => 'cExpr', :required => true],
            Arg[:name => 'nMilliseconds', :type => Numeric, :required => true] do |cExpr, nMilliseconds|
              cExpr = cExpr.is_a?(::String) ? cExpr : ''
              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.pdf) 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
            (if @field.has_key?(:FT)
              case @field.FT.value
                when PDF::Field::Type::BUTTON
                  if @fields.has_key?(:Ff)
                    flags = @field.Ff.value

                    if (flags & Origami::Annotation::Widget::Button::Flags::PUSHBUTTON) != 0
                      'button'
                    elsif (flags & Origami::Annotation::Widget::Button::Flags::RADIO) != 0
                      'radiobox'
                    else
                      'checkbox'
                    end
                  end
                when PDF::Field::Type::TEXT then 'text'
                when PDF::Field::Type::SIGNATURE then 'signature'
                when PDF::Field::Type::CHOICE
                  if @field.has_key?(:Ff)
                    if (@field.Ff.value & Origami::Annotation::Widget::Choice::Flags::COMBO).zero?
                      'listbox'
                    else
                      'combobox'
                    end
                  end
              end
            end).to_s
          end

        end

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

          def initialize(engine, name, size, path = nil, creationDate = nil, modDate = nil, description = nil, mimeType = nil)

            super(engine)

            @name, @path, @size = name, path, size
            @creationDate, @modDate = creationDate, modDate
            @description, @MIMEType = description, mimeType
          end
        end

      end

      class JavaScript::EngineError < Exception; end

      class JavaScript::Engine

        attr_reader :global
        attr_reader :context
        attr_reader :options
        attr_reader :parseInt

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

          @global = JavaScript::Doc.new(self, pdf)
          app = JavaScript::App.new(self)
          acrohelp = JavaScript::Acrohelp.new(self)
          global = JavaScript::Global.new(self)
          console = JavaScript::Console.new(self)
          util = JavaScript::Util.new(self)

          @context = V8::Context.new(:with => @global)
          @context['app'] = app
          @context['acrohelp'] = acrohelp
          @context['console'] = console
          @context['global'] = global
          @context['util'] = util

          @parseInt = @context['parseInt']
          @hooks = {}
        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)
          if @hooks.has_key?(name)
            @context[name] = @hooks[name][0]
          end
        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.pdf.eval_js(self.value)
      end
    end

    class Stream
      #
      # Evaluates the current Stream as JavaScript.
      #
      def eval_js
        self.pdf.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
  end
end

