# -*- 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-2022 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/dictionary' require 'hexapdf/error' module HexaPDF class Document # This class provides methods for creating and managing the destinations of a PDF file. # # A destination describes a particular view of a PDF document, consisting of the page, the view # location and a magnification factor. See Destination for details. # # Such destinations may be directly specified where needed, e.g. for link annotations, or they # may be named and later referenced through the name. This class allows to create destinations # with or without a name. # # See: PDF1.7 s12.3.2 class Destinations # Wraps an explicit destination array to allow easy access to query its properties. # # A *destination array* has the form # # [page, type, *arguments] # # where +page+ is either a page object or a page number (in case of a destination to a page in # a remote PDF document), +type+ is the destination type (see below) and +arguments+ are the # required arguments for the specific type of destination. # # == Destination Types # # There are eight different types of destinations, each taking different arguments. The # arguments are marked up in the list below and are in the correct order for use in the # destination array. The first name in the list is the PDF internal name, the second one the # explicit, more descriptive one used by HexaPDF (though the PDF internal name can also be # used): # # :XYZ, :xyz:: # Display the page with the given (+left+, +top+) coordinate at the upper-left corner of # the window and the specified magnification (+zoom+) factor. A +nil+ value for any # argument means not changing it from the current value. # # :Fit, :fit_page:: # Display the page so that it fits horizontally and vertically within the window. # # :FitH, :fit_page_horizontal:: # Display the page so that it fits horizontally within the window, with the given +top+ # coordinate being at the top of the window. A +nil+ value for +top+ means not changing it # from the current value. # # :FitV, :fit_page_vertical:: # Display the page so that it fits vertically within the window, with the given +left+ # coordinate being at the left of the window. A +nil+ value for +left+ means not changing # it from the current value. # # :FitR, :fit_rectangle:: # Display the page so that the rectangle specified by (+left+, +bottom+)-(+right+, +top+) # fits horizontally and vertically within the window. # # :FitB, :fit_bounding_box:: # Display the page so that its bounding box fits horizontally and vertically within the # window. # # :FitBH, :fit_bounding_box_horizontal:: # Display the page so that its bounding box fits horizontally within the window, with the # given +top+ coordinate being at the top of the window. A +nil+ value for +top+ means not # changing it from the current value. # # :FitBV, :fit_bounding_box_vertical:: # Display the page so that its bounding box fits vertically within the window, with the # given +left+ coordinate being at the left of the window. A +nil+ value for +left+ means # not changing it from the current value. class Destination TYPE_MAPPING = { #:nodoc: XYZ: :xyz, Fit: :fit_page, FitH: :fit_page_horizontal, FitV: :fit_page_vertical, FitR: :fit_rectangle, FitB: :fit_bounding_box, FitBH: :fit_bounding_box_horizontal, FitBV: :fit_bounding_box_vertical, } REVERSE_TYPE_MAPPING = Hash[*TYPE_MAPPING.flatten.reverse] #:nodoc: # Returns +true+ if the destination is valid. def self.valid?(destination) TYPE_MAPPING.key?(destination[1]) && (destination[0].kind_of?(Integer) || destination[0]&.type == :Page) && destination[2..-1].all? {|item| item.nil? || item.kind_of?(Numeric) } end # Creates a new Destination for the given +destination+ which may be an explicit destination # array or a dictionary with a /D entry (as allowed for a named destination). def initialize(destination) @destination = if destination.kind_of?(HexaPDF::Dictionary) || destination.kind_of?(Hash) destination[:D] else destination end end # Returns +true+ if the destination references a destination in a remote document. def remote? @destination[0].kind_of?(Numeric) end # Returns the referenced page. # # The return value is either a page object or, in case of a destination to a remote # document, a page number. def page @destination[0] end # Returns the type of destination. def type TYPE_MAPPING[@destination[1]] end # Returns the argument +left+ if used by the destination, raises an error otherwise. def left case type when :xyz, :fit_page_vertical, :fit_rectangle, :fit_bounding_box_vertical @destination[2] else raise HexaPDF::Error, "No such argument for destination type #{type}" end end # Returns the argument +top+ if used by the destination, raises an error otherwise. def top case type when :xyz @destination[3] when :fit_page_horizontal, :fit_bounding_box_horizontal @destination[2] when :fit_rectangle @destination[5] else raise HexaPDF::Error, "No such argument for destination type #{type}" end end # Returns the argument +right+ if used by the destination, raises an error otherwise. def right case type when :fit_rectangle @destination[4] else raise HexaPDF::Error, "No such argument for destination type #{type}" end end # Returns the argument +bottom+ if used by the destination, raises an error otherwise. def bottom case type when :fit_rectangle @destination[3] else raise HexaPDF::Error, "No such argument for destination type #{type}" end end # Returns the argument +zoom+ if used by the destination, raises an error otherwise. def zoom case type when :xyz @destination[4] else raise HexaPDF::Error, "No such argument for destination type #{type}" end end # Returns +true+ if the destination is valid. def valid? self.class.valid?(@destination) end # Returns the wrapped destination array. def value @destination end end include Enumerable # Creates a new Destinations object for the given PDF document. def initialize(document) @document = document end # :call-seq: # destinations.use_or_create(name) -> name # destinations.use_or_create(destination) -> destination # destinations.use_or_create(page) -> destination # destinations.use_or_create(type:, page, **options) -> destination # # Uses the given destination name/array or creates a destination array based on the given # +value+. # # This is the main utility method for other parts of HexaPDF for getting a valid destination # array based on various different types of the given +value+: # # String:: # # If a string is provided, it is assumed to be a named destination. If the named # destination exists, the value itself is returned. Otherwise an error is raised. # # Array:: # # If a valid destination array is provided, it is returned. Otherwise an error is raised. # # Page dictionary:: # # If the value is a valid page dictionary object, a fit to page (#create_fit_page) # destination array is created and returned. # # Integer:: # # If the value is an integer, it is interpreted as a zero-based page index and a fit to # page (#create_fit_page) destination array is created and returned. # # Hash containing at least :type and :page:: # # If the value is a hash, the :type key specifies the type of the destination that should # be created and the :page key the target page. Which other keys are allowed depends on # the destination type, so see the various create_XXX methods. Uses #create to do the job. def use_or_create(value) case value when String if self[value] value else raise HexaPDF::Error, "Named destination '#{value}' doesn't exist" end when Array raise HexaPDF::Error, "Invalid destination array" unless Destination.new(value).valid? value when HexaPDF::Dictionary if value.type != :Page raise HexaPDF::Error, "Invalid dictionary type '#{value.type}' given, needs to be a page" end create_fit_page(value) when Integer if value < 0 || value >= @document.pages.count raise ArgumentError, "Page index #{value} out of bounds" end create_fit_page(@document.pages[value]) when Hash type = value.delete(:type) { raise ArgumentError, "Missing keyword argument :type" } page = value.delete(:page) { raise ArgumentError, "Missing keyword argument :page" } create(type, page, **value) else raise ArgumentError, "Invalid argument type '#{value.class}'" end end # :call-seq: # destinations.create(type, page, **options) -> dest or name # # Creates a new destination array with the given +type+ (see Destination for all available # type names; PDF internal type names are also allowed) and +page+ by calling the respective # +create_type+ method. def create(type, page, **options) send("create_#{Destination::TYPE_MAPPING.fetch(type, type)}", page, **options) end # :call-seq: # destinations.create_xyz(page, left: nil, top: nil, zoom: nil) -> dest # destinations.create_xyz(page, name: nil, left: nil, top: nil, zoom: nil) -> name # # Creates a new xyz destination array for the given arguments and returns it or, in case # a name is given, the name. # # The arguments +page+, +left+, +top+ and +zoom+ are described in detail in the Destination # class description. # # If the argument +name+ is given, the created destination array is added to the destinations # name tree under that name for reuse later, overwriting an existing entry if there is one. def create_xyz(page, name: nil, left: nil, top: nil, zoom: nil) destination = [page, Destination::REVERSE_TYPE_MAPPING.fetch(:xyz), left, top, zoom] name ? (add(name, destination); name) : destination end # :call-seq: # destinations.create_fit_page(page) -> dest # destinations.create_fit_page(page, name: nil) -> name # # Creates a new fit to page destination array for the given arguments and returns it or, in # case a name is given, the name. # # The argument +page+ is described in detail in the Destination class description. # # If the argument +name+ is given, the created destination array is added to the destinations # name tree under that name for reuse later, overwriting an existing entry if there is one. def create_fit_page(page, name: nil) destination = [page, Destination::REVERSE_TYPE_MAPPING.fetch(:fit_page)] name ? (add(name, destination); name) : destination end # :call-seq: # destinations.create_fit_page_horizontal(page, top: nil) -> dest # destinations.create_fit_page_horizontal(page, name: nil, top: nil) -> name # # Creates a new fit page horizontal destination array for the given arguments and returns it # or, in case a name is given, the name. # # The arguments +page and +top+ are described in detail in the Destination class description. # # If the argument +name+ is given, the created destination array is added to the destinations # name tree under that name for reuse later, overwriting an existing entry if there is one. def create_fit_page_horizontal(page, name: nil, top: nil) destination = [page, Destination::REVERSE_TYPE_MAPPING.fetch(:fit_page_horizontal), top] name ? (add(name, destination); name) : destination end # :call-seq: # destinations.create_fit_page_vertical(page, left: nil) -> dest # destinations.create_fit_page_vertical(page, name: nil, left: nil) -> name # # Creates a new fit page vertical destination array for the given arguments and returns it or, # in case a name is given, the name. # # The arguments +page and +left+ are described in detail in the Destination class description. # # If the argument +name+ is given, the created destination array is added to the destinations # name tree under that name for reuse later, overwriting an existing entry if there is one. def create_fit_page_vertical(page, name: nil, left: nil) destination = [page, Destination::REVERSE_TYPE_MAPPING.fetch(:fit_page_vertical), left] name ? (add(name, destination); name) : destination end # :call-seq: # destinations.create_fit_rectangle(page, left:, bottom:, right:, top:) -> dest # destinations.create_fit_rectangle(page, name: nil, left:, bottom:, right:, top:) -> name # # Creates a new fit to rectangle destination array for the given arguments and returns it or, # in case a name is given, the name. # # The arguments +page+, +left+, +bottom+, +right+ and +top+ are described in detail in the # Destination class description. # # If the argument +name+ is given, the created destination array is added to the destinations # name tree under that name for reuse later, overwriting an existing entry if there is one. def create_fit_rectangle(page, left:, bottom:, right:, top:, name: nil) destination = [page, Destination::REVERSE_TYPE_MAPPING.fetch(:fit_rectangle), left, bottom, right, top] name ? (add(name, destination); name) : destination end # :call-seq: # destinations.create_fit_bounding_box(page) -> dest # destinations.create_fit_bounding_box(page, name: nil) -> name # # Creates a new fit to bounding box destination array for the given arguments and returns it # or, in case a name is given, the name. # # The argument +page+ is described in detail in the Destination class description. # # If the argument +name+ is given, the created destination array is added to the destinations # name tree under that name for reuse later, overwriting an existing entry if there is one. def create_fit_bounding_box(page, name: nil) destination = [page, Destination::REVERSE_TYPE_MAPPING.fetch(:fit_bounding_box)] name ? (add(name, destination); name) : destination end # :call-seq: # destinations.create_fit_bounding_box_horizontal(page, top: nil) -> dest # destinations.create_fit_bounding_box_horizontal(page, name: nil, top: nil) -> name # # Creates a new fit bounding box horizontal destination array for the given arguments and # returns it or, in case a name is given, the name. # # The arguments +page and +top+ are described in detail in the Destination class description. # # If the argument +name+ is given, the created destination array is added to the destinations # name tree under that name for reuse later, overwriting an existing entry if there is one. def create_fit_bounding_box_horizontal(page, name: nil, top: nil) destination = [page, Destination::REVERSE_TYPE_MAPPING.fetch(:fit_bounding_box_horizontal), top] name ? (add(name, destination); name) : destination end # :call-seq: # destinations.create_fit_bounding_box_vertical(page, left: nil) -> dest # destinations.create_fit_bounding_box_vertical(page, name: nil, left: nil) -> name # # Creates a new fit bounding box vertical destination array for the given arguments and # returns it or, in case a name is given, the name. # # The arguments +page and +left+ are described in detail in the Destination class description. # # If the argument +name+ is given, the created destination array is added to the destinations # name tree under that name for reuse later, overwriting an existing entry if there is one. def create_fit_bounding_box_vertical(page, name: nil, left: nil) destination = [page, Destination::REVERSE_TYPE_MAPPING.fetch(:fit_bounding_box_vertical), left] name ? (add(name, destination); name) : destination end # :call-seq: # destinations.add(name, destination) # # Adds the given +destination+ under +name+ to the destinations name tree. # # If the name does already exist, an error is raised. def add(name, destination) destinations.add_entry(name, destination) end # :call-seq: # destinations.delete(name) -> destination # # Deletes the given destination from the destinations name tree and returns it or +nil+ if no # destination was registered under that name. def delete(name) destinations.delete_entry(name) end # :call-seq: # destinations.resolve(string_name) -> destination or nil # destinations.resolve(symbol_name) -> destination or nil # destinations.resolve(dest_array) -> destination or nil # # Resolves the given value to a valid destination object, if possible, or otherwise returns # +nil+. # # * If the given value is a string, it is treated as a destination name and looked up in the # destination name tree. # # * If the given value is a symbol, it is treated as an old-style destination name and looked # up in the destination dictionary. # # * If the given value is an array, it is treated as a destination array itself. def resolve(value) result = case value when String destinations.find_entry(value) when PDFArray value.value when Array value when Symbol @document.catalog[:Dests]&.[](value) end result = Destination.new(result) if result result&.valid? ? result : nil end # :call-seq: # destinations[name] -> destination # # Returns the destination registered under the given +name+ or +nil+ if no destination was # registered under that name. def [](name) destinations.find_entry(name) end # :call-seq: # destinations.each {|name, dest| block } -> destinations # destinations.each -> Enumerator # # Iterates over all named destinations of the PDF, yielding the name and the destination # wrapped into a Destination object. def each return to_enum(__method__) unless block_given? destinations.each_entry do |name, dest| yield(name, Destination.new(dest)) end self end private # Returns the root of the destinations name tree. def destinations @document.catalog.names.destinations end end end end