# -*- 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-2024 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/layout' module HexaPDF class Document # This class provides methods for working with classes in the HexaPDF::Layout module. # # Often times the layout related classes are used through HexaPDF::Composer which makes it easy # to create documents. However, sometimes one wants to have a bit more control or do something # special and use the HexaPDF::Layout classes directly. This is possible but it is better to use # those classes through an instance of this class because it makes it more convenient and ties # everything together. Incidentally, HexaPDF::Composer relies on this class for a good part of # its work. # # # == Boxes # # The main focus of the class is on providing convenience methods for creating box objects. The # most often used box classes like HexaPDF::Layout::TextBox or HexaPDF::Layout::ImageBox can be # created through dedicated methods: # # * #text_box # * #formatted_text_box # * #image_box # * #lorem_ipsum_box # # Other, more general boxes don't have their own method but can be created through the general # #box method. This method uses the 'layout.boxes.map' configuration option. # # Additionally, the +_box+ suffix can be omitted, so calling #text, #formatted_text and #image # also works. Furthermore, all box names defined in the 'layout.boxes.map' configuration option # can be used as method names (with or without a +_box+ suffix) and will invoke #box, i.e. # #column and #column_box will also work. # # # == Box Styles # # All box creation methods accept Layout::Style objects or names for style objects (defined via # #style). This allows one to predefine certain styles (like first level heading, second level # heading, paragraph, ...) and consistently use them throughout the document creation process. # # One style property, Layout::Style#font, is handled specially: # # * If no font is set on a style, the font "Times" is automatically set because otherwise there # would be problems with text drawing operations (font is the only style property that has no # valid default value). # # * Standard style objects only allow font wrapper objects to be set via the Layout::Style#font # method. This class makes usage easier by allowing strings or an array [name, options_hash] # to be used, like with e.g Content::Canvas#font. So to use Helvetica as font, one could just # do: # # style.font = 'Helvetica' # # And if Helvetica in its bold variant should be used it would be: # # style.font = ['Helvetica', variant: :bold] # # Helvetica in bold could also be set the conventional way: # # style.font = 'Helvetica bold' # # However, using an array it is also possible to specify other options when setting a font, # like the :subset option. # class Layout # This class is used when a box can contain child boxes and the creation of such boxes should # be seemlessly doable when creating the parent node. It is yieled, for example, by Layout#box # to collect the children for the created box. # # A box can be added to the list of collected children in the following ways: # # #<<:: This appends the given box to the list. # # text_box, formatted_text_box, image_box, ...:: Any method accepted by the Layout class. # # text, formatted_text, image, ...:: Any method accepted by the Layout class without the _box # suffix. # # list, column, ...:: Any name registered with the configuration option +layout.boxes.map+. # # The special method #multiple allows adding multiple boxes as a single array to the collected # children. # # Example: # # document.layout.box(:list) do |list| # list is a ChildrenCollector # list.text_box("Some text here") # layout method # list.image(image_path) # layout method without _box suffix # list.column(columns: 3) do |column| # registered box name # column.text("Text in column") # column << document.layout.lorem_ipsum_box # adding a Box instance # end # end class ChildrenCollector # Creates a children collector, yields it and then returns the collected children. def self.collect(layout) collector = new(layout) yield(collector) collector.children end # The collected children attr_reader :children # Create a new ChildrenCollector for the given +layout+ (a HexaPDF::Document::Layout) # instance. def initialize(layout) @layout = layout @children = [] end # :nodoc: def method_missing(name, *args, **kwargs, &block) if @layout.box_creation_method?(name) @children << @layout.send(name, *args, **kwargs, &block) else super end end # :nodoc: def respond_to_missing?(name, _private) @layout.box_creation_method?(name) || super end # Appends the given box to the list of collected children. def <<(box) @children << box end # Yields a ChildrenCollector instance and adds the collected children as a single array to # the list of collected children. def multiple(&block) @children << self.class.collect(@layout, &block) end end # The mapping of style name (a Symbol) to Layout::Style instance. attr_reader :styles # Creates a new Layout object for the given PDF document. def initialize(document) @document = document @styles = {base: HexaPDF::Layout::Style.new} end # :call-seq: # layout.style(name) -> style # layout.style(name, base: :base, **properties) -> style # # Creates or updates the Layout::Style object called +name+ with the given property values and # returns it. # # If neither +base+ nor any style properties are specified, the style +name+ is just returned. # # This method allows convenient access to the stored styles and to update them. Such styles # can then be used by name in the various box creation methods, e.g. #text_box or #image_box. # # If the style +name+ does not exist yet and the argument +base+ specifies the name of another # style, that style is duplicated and used as basis for the style. This also means that the # referenced +base+ style needs be defined first! # # The special name :base should be used for setting the base style which is used for the # +base+ argument when no specific style is specified. # # Note that the style property 'font' is handled specially, see the class documentation for # details. # # Example: # # layout.style(:base, font_size: 12, leading: 1.2) # layout.style(:header, font: 'Helvetica', fill_color: "008") # layout.style(:header1, base: :header, font_size: 30) # # See: HexaPDF::Layout::Style def style(name, base: :base, **properties) style = @styles[name] ||= (@styles.key?(base) ? @styles[base].dup : HexaPDF::Layout::Style.new) style.update(**properties) unless properties.empty? style end # Creates an inline box for use together with text fragments. # # The +valign+ argument ist used to specify the vertical alignment of the box within the text # line. See HexaPDF::Layout::Line for details. # # If a box instance is provided as first argument, it is used. Otherwise the first argument # has to be the name of a box creation method and +args+, +kwargs+ and +block+ are passed to # it. # # Example: # # layout.inline_box(:text, "Hallo") # layout.inline_box(:list) {|list| list.text("Hallo") } def inline_box(box_or_name, *args, valign: :baseline, **kwargs, &block) box = if box_or_name.kind_of?(HexaPDF::Layout::Box) box_or_name else send(box_or_name, *args, **kwargs, &block) end HexaPDF::Layout::InlineBox.new(box, valign: valign) end # Creates the named box and returns it. # # The +name+ argument refers to the registered name of the box class that is looked up in the # 'layout.boxes.map' configuration option. The +box_options+ are passed as-is to the # initialization method of that box class. # # If a block is provided, a ChildrenCollector is yielded and the collected children are passed # to the box initialization method via the :children keyword argument. There is one exception # to this rule in case +name+ is +base+: The provided block is passed to the initialization # method of the base box class to function as drawing method. # # See #text_box for details on +width+, +height+ and +style+ (note that there is no # +style_properties+ argument). # # Example: # # layout.box(:column, columns: 2, gap: 15) # => column_box_instance # layout.box(:column) do |column| # column box with one child # column.lorem_ipsum # end # layout.box(width: 100) do |canvas, box| # canvas.line(0, 0, box.content_width, box.content_height).stroke # end def box(name = :base, width: 0, height: 0, style: nil, **box_options, &block) if block_given? if name == :base box_block = block elsif !box_options.key?(:children) box_options[:children] = ChildrenCollector.collect(self, &block) end end box_class_for_name(name).new(width: width, height: height, style: retrieve_style(style), **box_options, &box_block) end # Creates an array of HexaPDF::Layout::TextFragment objects for the given +text+. # # This method uses the configuration option 'font.on_invalid_glyph' to map Unicode characters # without a valid glyph in the given font to zero, one or more glyphs in a fallback font. # # +style+, +style_properties+:: # The text is styled using the given +style+. This can either be a style name set via # #style or anything Layout::Style::create accepts. If any additional +style_properties+ # are specified, the style is duplicated and the additional styles are applied. # # +properties+:: # This can be used to set custom properties on the created text fragments. See # Layout::Box#properties for details and usage. def text_fragments(text, style: nil, properties: nil, **style_properties) style = retrieve_style(style, style_properties) fragments = HexaPDF::Layout::TextFragment.create_with_fallback_glyphs( text, style, &@document.config['font.on_invalid_glyph'] ) fragments.each {|f| f.properties.update(properties) } if properties fragments end # Creates a HexaPDF::Layout::TextBox for the given text. # # This method is of the two main methods for creating text boxes, the other being # #formatted_text_box. # # +width+, +height+:: # The arguments +width+ and +height+ are used as constraints and are respected when # fitting the box. The default value of 0 means that no constraints are set. # # +style+, +style_properties+:: # The box and the text are styled using the given +style+. This can either be a style name # set via #style or anything Layout::Style::create accepts. If any additional # +style_properties+ are specified, the style is duplicated and the additional styles are # applied. # # +properties+:: # This can be used to set custom properties on the created text box. See # Layout::Box#properties for details and usage. # # +box_style+:: # Sometimes it is necessary for the box to have a different style than the text, e.g. when # using overlays. In such a case use +box_style+ for specifiying the style of the box (a # style name set via #style or anything Layout::Style::create accepts). # # The +style+ together with the +style_properties+ will be used for the text style. # # Examples: # # layout.text_box("Test is on " * 15) # layout.text_box("Now " * 7, width: 100) # layout.text_box("Another test", font_size: 15, fill_color: "hp-blue") # layout.text_box("Different box style", fill_color: 'white', box_style: { # underlays: [->(c, b) { c.rectangle(0, 0, b.content_width, b.content_height).fill }] # }) # # See: #formatted_text_box, HexaPDF::Layout::TextBox, HexaPDF::Layout::TextFragment def text_box(text, width: 0, height: 0, style: nil, properties: nil, box_style: nil, **style_properties) style = retrieve_style(style, style_properties) box_style = (box_style ? retrieve_style(box_style) : style) box_class_for_name(:text).new(items: text_fragments(text, style: style), width: width, height: height, properties: properties, style: box_style) end # Creates a HexaPDF::Layout::TextBox like #text_box but allows parts of the text to be # formatted differently. # # The argument +data+ needs to be an array of String, HexaPDF::Layout::InlineBox and/or Hash # objects and is transformed so that it is suitable as argument for the text box # initialization method. # # * A String object is treated like {text: data}. # # * A HexaPDF::Layout::InlineBox is used without modification. # # * Hashes can contain any style properties and the following special keys: # # text:: The text to be formatted. If this is set and :box is not, the hash will be # transformed into text fragments. # # link:: A URL that should be linked to. If no text is provided but a link, the link is used # for the text. If this is set and :box is not, the hash will be transformed into # text fragments with an appropriate link overlay. # # style:: The style to use as base style instead of the style created from the +style+ and # +style_properties+ arguments. This can either be a style name set via #style or # anything HexaPDF::Layout::Style::create allows. # # If any style properties are set, the used style is duplicated and the additional # properties applied. # # The final style is used for a created text fragment. # # properties:: The custom properties that should be set on the created text fragments. # # box:: An inline box to be used. If this is set, the hash will be transformed into an # inline box. # # The value must be one or more (as an array) positional arguments to be used with the # #inline_box method. The rest of the hash keys are passed as keyword arguments to # #inline_box except for :block which would be passed as the block. # # See #text_box for details on +width+, +height+, +style+, +style_properties+, +properties+ # and +box_style+. # # Examples: # # # Text without special styling # layout.formatted_text_box(["Some string"]) # # # A predefined inline box # ibox = layout.inline_box(:text, 'Hello') # layout.formatted_text_box([ibox]) # # # Text with styling properties # layout.formatted_text_box([{text: "string", fill_color: 128}]) # # # Text referencing a base style # layout.formatted_text_box([{text: "string", style: :bold}]) # # # Text with a link # layout.formatted_text_box([{link: "https://example.com", # fill_color: 'blue', text: "Example"}]) # # # Inline boxes created from the given data # layout.formatted_text_box([{box: [:text, "string"], valign: :top}]) # block = lambda {|list| list.text("First item"); list.text("Second item") } # layout.formatted_text_box(["Some ", {box: :list, item_spacing: 10, block: block}]) # # # Combining the above variants # layout.formatted_text_box(["Hello", {box: [:text, 'World!']}, "Here comes a ", # {link: 'https://example.com', text: 'link'}, '!', # {text: 'And more!', style: :bold, font_size: 20}]) # # See: #text_box, #inline_box, HexaPDF::Layout::TextBox, HexaPDF::Layout::TextFragment, # HexaPDF::Layout::InlineBox def formatted_text_box(data, width: 0, height: 0, style: nil, properties: nil, box_style: nil, **style_properties) style = retrieve_style(style, style_properties) box_style = (box_style ? retrieve_style(box_style) : style) data = data.inject([]) do |result, item| case item when String result.concat(text_fragments(item, style: style)) when Hash if (args = item.delete(:box)) block = item.delete(:block) result << inline_box(*args, **item, &block) else link = item.delete(:link) (item[:overlays] ||= []) << [:link, {uri: link}] if link text = item.delete(:text) || link || "" item_properties = item.delete(:properties) frag_style = retrieve_style(item.delete(:style) || style, item) result.concat(text_fragments(text, style: frag_style, properties: item_properties)) end when HexaPDF::Layout::InlineBox result << item else raise ArgumentError, "Invalid item of class #{item.class} in data array" end end box_class_for_name(:text).new(items: data, width: width, height: height, properties: properties, style: box_style) end # Creates a HexaPDF::Layout::ImageBox for the given image. # # The +file+ argument can be anything that is accepted by HexaPDF::Document::Images#add or a # HexaPDF::Type::Form object. # # See #text_box for details on +width+, +height+, +style+, +style_properties+ and # +properties+. # # Examples: # # layout.image_box(machu_picchu, border: {width: 3}) # layout.image_box(machu_picchu, height: 30) # # See: HexaPDF::Layout::ImageBox def image_box(file, width: 0, height: 0, properties: nil, style: nil, **style_properties) style = retrieve_style(style, style_properties) image = file.kind_of?(HexaPDF::Stream) ? file : @document.images.add(file) box_class_for_name(:image).new(image: image, width: width, height: height, properties: properties, style: style) end # This helper class is used by Layout#table_box to allow specifying the keyword arguments used # when converting cell data to box instances. class CellArgumentCollector # Stores a single keyword argument definition for a number of rows/columns. ArgumentInfo = Struct.new(:rows, :cols, :args) # Returns all stored ArgumentInfo instances. attr_reader :argument_infos # Creates a new instance, providing the number of rows and columns of the table. def initialize(number_of_rows, number_of_columns) @argument_infos = [] @number_of_rows = number_of_rows @number_of_columns = number_of_columns end # Stores the keyword arguments in +args+ for the given 0-based rows and columns which can # either be a single number or a range of numbers. def []=(rows = 0..-1, cols = 0..-1, args) rows = adjust_range(rows.kind_of?(Integer) ? rows..rows : rows, @number_of_rows) cols = adjust_range(cols.kind_of?(Integer) ? cols..cols : cols, @number_of_columns) @argument_infos << ArgumentInfo.new(rows, cols, args) end # Retrieves the merged keyword arguments for the cell in +row+ and +col+. # # Earlier defined arguments are overridden by later ones, except for the +:cell+ key which # is merged. def retrieve_arguments_for(row, col) @argument_infos.each_with_object({}) do |arg_info, result| next unless arg_info.rows.cover?(row) && arg_info.cols.cover?(col) if arg_info.args[:cell] arg_info.args[:cell] = (result[:cell] || {}).merge(arg_info.args[:cell]) end result.update(arg_info.args) end end private # Adjusts the +range+ so that both the begin and the end of the range are zero or positive # integers smaller than +max+. def adjust_range(range, max) (range.begin % max)..(range.end % max) end end # Creates a HexaPDF::Layout::TableBox for the given table data. # # This method is a small wrapper around the actual class and mainly facilitates transforming # the contents of the +data+ into the box instances needed by the table box implementation. # # In addition to everything the table box implementation allows for +data+, it is also # possible to specify strings as cell contents. Those strings will be converted to text boxes # by using the #text_box method. *Note* that this functionality is *not* available for the # header and footer! # # Additional arguments for the #text_box invocations can be specified using the optional block # that yields a CellArgumentCollector instance. This allows customization of the text boxes. # By specifying the special key +:cell+ it is also possible to assign style properties to the # cells themselves. # # See HexaPDF::Layout::TableBox::new for details on +column_widths+, +header+, +footer+, and # +cell_style+. # # See #text_box for details on +width+, +height+, +style+, +style_properties+ and # +properties+. # # Examples: # # layout.table_box([[layout.text('A'), layout.text('B')], # [layout.image(image_path), layout.text('D')]] # layout.table_box([['A', 'B'], [layout.image(image_path), 'D]]) # same as above # # layout.table_box([['A', 'B'], ['C', 'D]]) do |args| # # assign the predefined style :cell_text to all texts # args[] = {style: :cell_text} # # row 0 has a grey background and bold text # args[0] = {font: 'Helvetica bold', cell: {background_color: 'eee'}} # # text in last column is right aligned # args[0..-1, -1] = {text_align: :right} # end # # See: HexaPDF::Layout::TableBox def table_box(data, column_widths: nil, header: nil, footer: nil, cell_style: nil, width: 0, height: 0, style: nil, properties: nil, **style_properties) style = retrieve_style(style, style_properties) cells = HexaPDF::Layout::TableBox::Cells.new(data, cell_style: cell_style) collector = CellArgumentCollector.new(cells.number_of_rows, cells.number_of_columns) yield(collector) if block_given? cells.style do |cell| args = collector.retrieve_arguments_for(cell.row, cell.column) cstyle = args.delete(:cell) result = case cell.children when Array, HexaPDF::Layout::Box cell.children else text_box(cell.children.to_s, **args) end cell.children = result cell.style.update(**cstyle) if cstyle end box_class_for_name(:table).new(cells: cells, column_widths: column_widths, header: header, footer: footer, cell_style: cell_style, width: width, height: height, properties: properties, style: style) end LOREM_IPSUM = [ # :nodoc: "Lorem ipsum dolor sit amet, con\u{00AD}sectetur adipis\u{00AD}cing elit, sed " \ "do eiusmod tempor incididunt ut labore et dolore magna aliqua.", "Ut enim ad minim veniam, quis nostrud exer\u{00AD}citation ullamco laboris nisi ut " \ "aliquip ex ea commodo consequat.", "Duis aute irure dolor in reprehen\u{00AD}derit in voluptate velit esse cillum dolore " \ "eu fugiat nulla pariatur.", "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt " \ "mollit anim id est laborum.", ] # Uses #text_box to create +count+ paragraphs with +sentences+ number of sentences (1 to 4) of # lorem ipsum text. # # The +text_box_properties+ arguments are passed as is to #text_box. def lorem_ipsum_box(sentences: 4, count: 1, **text_box_properties) text_box(([LOREM_IPSUM[0, sentences].join(" ")] * count).join("\n\n"), **text_box_properties) end BOX_METHOD_NAMES = [:text, :formatted_text, :image, :table, :lorem_ipsum] #:nodoc: # Allows creating boxes using more convenient method names: # # * #text for #text_box # * #formatted_text for #formatted_text_box # * #image for #image_box # * #lorem_ipsum for #lorem_ipsum_box # * The name of a pre-defined box class like #column will invoke #box appropriately. Same if # used with a '_box' suffix. def method_missing(name, *args, **kwargs, &block) name_without_box = name.to_s.sub(/_box$/, '').intern if BOX_METHOD_NAMES.include?(name) send("#{name}_box", *args, **kwargs, &block) elsif @document.config['layout.boxes.map'].key?(name_without_box) box(name_without_box, *args, **kwargs, &block) else super end end # :nodoc: def respond_to_missing?(name, _private) box_creation_method?(name) || super end # :nodoc: def box_creation_method?(name) name = name.to_s.sub(/_box$/, '').intern BOX_METHOD_NAMES.include?(name) || @document.config['layout.boxes.map'].key?(name) || name == :box end private # Returns the configured box class for the given +name+. def box_class_for_name(name) @document.config.constantize('layout.boxes.map', name) do raise HexaPDF::Error, "Couldn't retrieve box class #{name} from configuration" end end # Retrieves the appropriate HexaPDF::Layout::Style object based on the +style+ and +properties+ # arguments. # # The +style+ argument specifies the style to retrieve. It can either be a registered style # name (see #style), a hash with style properties or +nil+. In the latter case the registered # style :base is used # # If the +properties+ hash is not empty, the retrieved style is duplicated and the properties # hash is applied to it. # # Finally, a default font (the one from the :base style or otherwise 'Times') is set if # necessary to ensure that the style object works in all cases. def retrieve_style(style, properties = nil) if style.kind_of?(Symbol) && !@styles.key?(style) raise HexaPDF::Error, "Style #{style} not defined" end style = HexaPDF::Layout::Style.create(@styles[style] || style || @styles[:base]) style = style.dup.update(**properties) unless properties.nil? || properties.empty? style.font(@styles[:base].font? && @styles[:base].font || 'Times') unless style.font? unless style.font.respond_to?(:pdf_object) name, options = *style.font style.font(@document.fonts.add(name, **(options || {}))) end style end end end end