lib/hexapdf/document/layout.rb in hexapdf-0.31.0 vs lib/hexapdf/document/layout.rb in hexapdf-0.32.0

- old
+ new

@@ -2,11 +2,11 @@ # #-- # This file is part of HexaPDF. # # HexaPDF - A Versatile PDF Creation and Manipulation Library For Ruby -# Copyright (C) 2014-2022 Thomas Leitner +# Copyright (C) 2014-2023 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): @@ -42,25 +42,35 @@ # 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 classs because it makes it more convenient and ties + # 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::ImagebBox can be - # created through dedicated methods. + # 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. + # #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 HexaPDF::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 @@ -124,33 +134,25 @@ # Create a new ChildrenCollector for the given +layout+ (a HexaPDF::Document::Layout) # instance. def initialize(layout) @layout = layout - @layout_boxes_map = layout.instance_variable_get(:@document).config['layout.boxes.map'] @children = [] end # :nodoc: def method_missing(name, *args, **kwargs, &block) - if @layout.respond_to?(name) + if @layout.box_creation_method?(name) @children << @layout.send(name, *args, **kwargs, &block) - elsif @layout.respond_to?("#{name}_box") - @children << @layout.send("#{name}_box", *args, **kwargs, &block) - elsif @layout_boxes_map.key?(name) - @children << @layout.box(name, *args, **kwargs, &block) else super end end # :nodoc: def respond_to_missing?(name, _private) - @layout.respond_to?(name) || - @layout.respond_to?("#{name}_box") || - @layout_boxes_map.key?(name) || - super + @layout.box_creation_method?(name) || super end # Appends the given box to the list of collected children. def <<(box) @children << box @@ -206,10 +208,32 @@ 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 @@ -220,12 +244,12 @@ # See #text_box for details on +width+, +height+ and +style+ (note that there is no # +style_properties+ argument). # # Example: # - # doc.layout.box(:column, columns: 2, gap: 15) # => column_box_instance - # doc.layout.box(:column) do |column| # column box with one child + # layout.box(:column, columns: 2, gap: 15) # => column_box_instance + # layout.box(:column) do |column| # column box with one child # column.lorem_ipsum # end def box(name, width: 0, height: 0, style: nil, **box_options, &block) if block_given? && !box_options.key?(:children) box_options[:children] = ChildrenCollector.collect(self, &block) @@ -260,14 +284,14 @@ # # The +style+ together with the +style_properties+ will be used for the text style. # # Examples: # - # layout.text("Test " * 15) - # layout.text("Now " * 7, width: 100) - # layout.text("Another test", font_size: 15, fill_color: "green") - # layout.text("Different box style", fill_color: 'white', box_style: { + # layout.text_box("Test " * 15) + # layout.text_box("Now " * 7, width: 100) + # layout.text_box("Another test", font_size: 15, fill_color: "green") + # 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, @@ -280,56 +304,83 @@ 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 and/or Hash objects: + # 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: # # * 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. + # text:: The text to be formatted. If this is set and :box is not, the hash will be + # transformed into a text fragment. # # link:: A URL that should be linked to. If no text is provided but a link, the link is used - # as text. + # for the text. If this is set and :box is not, the hash will be transformed into a + # text fragment with an appropriate link overlay. # # style:: The style to be use as base style instead of the style created from the +style+ # and +style_properties+ arguments. See HexaPDF::Layout::Style::create for allowed # values. # - # If any style properties are set, the used style is duplicated and the additional - # properties applied. + # 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. + # + # 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 that value of which would be passed as the block. + # # See #text_box for details on +width+, +height+, +style+, +style_properties+, +properties+ # and +box_style+. # # Examples: # # layout.formatted_text_box(["Some string"]) # layout.formatted_text_box(["Some ", {text: "string", fill_color: 128}]) # layout.formatted_text_box(["Some ", {link: "https://example.com", # fill_color: 'blue', text: "Example"}]) # layout.formatted_text_box(["Some ", {text: "string", style: {font_size: 20}}]) + # layout.formatted_text_box(["Some ", {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}]) # # See: #text_box, HexaPDF::Layout::TextBox, HexaPDF::Layout::TextFragment 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.map! do |hash| - if hash.kind_of?(String) - HexaPDF::Layout::TextFragment.create(hash, style) + data.map! do |item| + case item + when String + HexaPDF::Layout::TextFragment.create(item, style) + when Hash + if (args = item.delete(:box)) + block = item.delete(:block) + inline_box(*args, **item, &block) + else + link = item.delete(:link) + (item[:overlays] ||= []) << [:link, {uri: link}] if link + text = item.delete(:text) || link || "" + properties = item.delete(:properties) + frag_style = retrieve_style(item.delete(:style) || style, item) + fragment = HexaPDF::Layout::TextFragment.create(text, frag_style) + fragment.properties.update(properties) if properties + fragment + end + when HexaPDF::Layout::InlineBox + item else - link = hash.delete(:link) - (hash[:overlays] ||= []) << [:link, {uri: link}] if link - text = hash.delete(:text) || link || "" - properties = hash.delete(:properties) - frag_style = retrieve_style(hash.delete(:style) || style, hash) - fragment = HexaPDF::Layout::TextFragment.create(text, frag_style) - fragment.properties.update(properties) if properties - fragment + 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 @@ -353,12 +404,11 @@ 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 - # :nodoc: - LOREM_IPSUM = [ + 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 " \ @@ -370,9 +420,42 @@ # Uses #text_box to create +count+ paragraphs 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, :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+.