# -*- 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/error' require 'hexapdf/content/graphics_state' module HexaPDF module Layout # A Style is a container for properties that describe the appearance of text or graphics. # # Each property except #font has a default value, so only the desired properties need to be # changed. # # Each property has three associated methods: # # property_name:: Getter method. # property_name(*args) and property_name=:: Setter method. # property_name?:: Tester method to see if a value has been set or if the default value has # already been used. class Style # Defines how the distance between the baselines of two adjacent text lines is determined: # # :single:: # :proportional with value 1. # # :double:: # :proportional with value 2. # # :proportional:: # The y_min of the first line and the y_max of the second line are multiplied with the # specified value, and the sum is used as baseline distance. # # :fixed:: # The distance between the baselines is set to the specified value. # # :leading:: # The distance between the baselines is set to the sum of the y_min of the first line, the # y_max of the second line and the specified value. class LineSpacing # The type of line spacing - see LineSpacing attr_reader :type # The value (needed for some types) - see LineSpacing attr_reader :value # Creates a new LineSpacing object for the given type which can be any valid line spacing # type or a LineSpacing object. def initialize(type:, value: 1) case type when :single @type = :proportional @value = 1 when :double @type = :proportional @value = 2 when :fixed, :proportional, :leading unless value.kind_of?(Numeric) raise ArgumentError, "Need a valid number for #{type} line spacing" end @type = type @value = value when LineSpacing @type = type.type @value = type.value when Integer, Float @type = :proportional @value = type else raise ArgumentError, "Invalid type #{type} for line spacing" end end # Returns the distance between the baselines of the two given Line objects. def baseline_distance(line1, line2) case type when :proportional then (line1.y_min.abs + line2.y_max) * value when :fixed then value when :leading then line1.y_min.abs + line2.y_max + value end end # Returns the gap between the two given Line objects, i.e. the distance between the y_min of # the first line and the y_max of the second line. def gap(line1, line2) case type when :proportional then (line1.y_min.abs + line2.y_max) * (value - 1) when :fixed then value - line1.y_min.abs - line2.y_max when :leading then value end end end # A Quad holds four values and allows them to be accessed by the names top, right, bottom and # left. Quads are normally used for holding values pertaining to boxes, like margins, paddings # or borders. class Quad # The value for top. attr_accessor :top # The value for bottom. attr_accessor :bottom # The value for left. attr_accessor :left # The value for right. attr_accessor :right # Creates a new Quad object. See #set for more information. def initialize(obj) set(obj) end # :call-seq: # quad.set(value) # quad.set(array) # quad.set(quad) # # Sets all values of the quad. # # * If a single value is provided that is neither a Quad nor an array, it is handled as if # an array with one value was given. # # * If a Quad is provided, its values are used. # # * If an array is provided, it depends on the number of elemens in it: # # * One value: All attributes are set to the same value. # * Two values: Top and bottom are set to the first value, left and right to the second # value. # * Three values: Top is set to the first, left and right to the second, and bottom to the # third value. # * Four or more values: Top is set to the first, right to the second, bottom to the third # and left to the fourth value. def set(obj) case obj when Quad @top = obj.top @bottom = obj.bottom @left = obj.left @right = obj.right when Array @top = obj[0] @bottom = obj[2] || obj[0] @left = obj[3] || obj[1] || obj[0] @right = obj[1] || obj[0] else @top = @bottom = @left = @right = obj end end # Returns +true+ if the quad effectively contains only one value. def simple? @top == @bottom && @top == @left && @top == @right end end # Represents the border of a rectangular area. class Border # The widths of each edge. See Quad. attr_reader :width # The colors of each edge. See Quad. attr_reader :color # The styles of each edge. See Quad. attr_reader :style # Specifies whether the border should be drawn inside the provided rectangle (+false+, # default) or on it (+true+). attr_accessor :draw_on_bounds # Creates a new border style. All arguments can be set to any value that a Quad can process. def initialize(width: 0, color: 0, style: :solid, draw_on_bounds: false) @width = Quad.new(width) @color = Quad.new(color) @style = Quad.new(style) @draw_on_bounds = draw_on_bounds end # Duplicates a Border object's properties. def initialize_copy(other) super @width = @width.dup @color = @color.dup @style = @style.dup end # Returns +true+ if there is no border. def none? width.simple? && width.top == 0 end # Draws the border onto the canvas. # # Depending on #draw_on_bounds the border is drawn inside the rectangle (x, y, w, h) or on # it. def draw(canvas, x, y, w, h) return if none? if draw_on_bounds x -= width.left / 2.0 y -= width.bottom / 2.0 w += (width.left + width.right) / 2.0 h += (width.top + width.bottom) / 2.0 end canvas.save_graphics_state do if width.simple? && color.simple? && style.simple? draw_simple_border(canvas, x, y, w, h) else draw_complex_border(canvas, x, y, w, h) end end end private # Draws the border assuming that only one width, style and color are used. def draw_simple_border(canvas, x, y, w, h) offset = width.top / 2.0 canvas.stroke_color(color.top). line_width(width.top). line_join_style(:miter). miter_limit(10). line_cap_style(line_cap_style(:top)) canvas.rectangle(x, y, w, h).clip_path.end_path if style.top == :solid canvas.line_dash_pattern(0). rectangle(x + offset, y + offset, w - 2 * offset, h - 2 * offset).stroke else canvas.line_dash_pattern(line_dash_pattern(:top, w)). line(x, y + h - offset, x + w, y + h - offset). line(x + w, y + offset, x, y + offset).stroke canvas.line_dash_pattern(line_dash_pattern(:right, h)). line(x + w - offset, y + h, x + w - offset, y). line(x + offset, y, x + offset, y + h).stroke end end # Draws a complex border, i.e. one where every edge is potentially differently styled. def draw_complex_border(canvas, x, y, w, h) left = x bottom = y right = left + w top = bottom + h inner_left = left + width.left inner_bottom = bottom + width.bottom inner_right = right - width.right inner_top = top - width.top if width.top > 0 canvas.save_graphics_state do canvas.polyline(left, top, right, top, inner_right, inner_top, inner_left, inner_top). clip_path.end_path canvas.stroke_color(color.top). line_width(width.top). line_cap_style(line_cap_style(:top)). line_dash_pattern(line_dash_pattern(:top, w)). line(left, top - width.top / 2.0, right, top - width.top / 2.0).stroke end end if width.right > 0 canvas.save_graphics_state do canvas.polyline(right, top, right, bottom, inner_right, inner_bottom, inner_right, inner_top). clip_path.end_path canvas.stroke_color(color.right). line_width(width.right). line_cap_style(line_cap_style(:right)). line_dash_pattern(line_dash_pattern(:right, h)). line(right - width.right / 2.0, top, right - width.right / 2.0, bottom).stroke end end if width.bottom > 0 canvas.save_graphics_state do canvas.polyline(right, bottom, left, bottom, inner_left, inner_bottom, inner_right, inner_bottom). clip_path.end_path canvas.stroke_color(color.bottom). line_width(width.bottom). line_cap_style(line_cap_style(:bottom)). line_dash_pattern(line_dash_pattern(:bottom, w)). line(right, bottom + width.bottom / 2.0, left, bottom + width.bottom / 2.0).stroke end end if width.left > 0 canvas.save_graphics_state do canvas.polyline(left, bottom, left, top, inner_left, inner_top, inner_left, inner_bottom). clip_path.end_path canvas.stroke_color(color.left). line_width(width.left). line_cap_style(line_cap_style(:left)). line_dash_pattern(line_dash_pattern(:left, h)). line(left + width.left / 2.0, bottom, left + width.left / 2.0, top).stroke end end end # Returns the line cap style for the given edge name. def line_cap_style(edge) case style.send(edge) when :solid then :butt when :dashed then :projecting_square when :dashed_round, :dotted then :round else raise ArgumentError, "Invalid border style specified: #{style.send(edge)}" end end # Returns the line dash pattern for the given edge name. The argument +length+ needs to # contain the length of the edge. def line_dash_pattern(edge, length) case style.send(edge) when :solid 0 when :dashed, :dashed_round # Due to the used line cap styles, a dash of length w appears with a length of 2w. The # gap between dashes is nominally 3w but adjusted so that full dashes start and end in # the corners. w = width.send(edge) count = [(length.to_f / (w * 3)).floor, 1].max gap = [(length - w * (count + 2)).to_f, 0].max / count HexaPDF::Content::LineDashPattern.new([w, gap], w * 0.5 + gap) when :dotted # Adjust the gap so that full dots appear in the corners. w = width.send(edge) gap = [(length - w).to_f / (length.to_f / (w * 2)).ceil, 1].max HexaPDF::Content::LineDashPattern.new([0, gap], [gap - w * 0.5, 0].max) end end end # Represents layers that can be drawn under or over a box. # # There are two ways to specify layers via #add: # # * Directly by providing a callable object. # # * By reference to a callable object or class in the 'style.layers_map' configuration option. # The reference name is looked up in the configuration option using # HexaPDF::Configuration#constantize. If the resulting object is a callable object, it is # used; otherwise it is assumed that it is a class and an object is instantiated, passing in # any options given on #add. # # The object resolved in this way needs to respond to #call(canvas, box) where +canvas+ is the # HexaPDF::Content::Canvas object on which it should be drawn and +box+ is a box-like object # (e.g. Box or TextFragment). The coordinate system is translated so that the origin is at the # bottom left corner of the box during the drawing operations. class Layers # Creates a new Layers object popuplated with the given +layers+. def initialize(layers = nil) @layers = [] layers&.each {|name, options| add(name, **(options || {})) } end # Duplicates the array holding the layers. def initialize_copy(other) super @layers = @layers.dup end # :call-seq: # layers.add {|canvas, box| block} # layers.add(name, **options) # # Adds a new layer object. # # The layer object can either be specified as a block or by reference to a configured layer # object in 'style.layers_map'. In this case +name+ is used as the reference and the options # are passed to layer object if it needs initialization. def add(name = nil, **options, &block) if block_given? || name.kind_of?(Proc) @layers << (block || name) elsif name @layers << [name, options] else raise ArgumentError, "Layer object name or block missing" end end # Draws all layer objects onto the canvas at the position [x, y] for the given box. def draw(canvas, x, y, box) return if none? canvas.translate(x, y) do each(canvas.context.document.config) do |layer| canvas.save_graphics_state { layer.call(canvas, box) } end end end # Yields all layer objects. Objects that have been specified via a reference are first # resolved using the provided configuration object. def each(config) #:yield: layer @layers.each do |obj, options| obj = config.constantize('style.layers_map', obj) unless obj.respond_to?(:call) obj = obj.new(**options) unless obj.respond_to?(:call) yield(obj) end end # Returns +true+ if there are no layers defined. def none? @layers.empty? end end # The LinkLayer class provides support for linking to in-document or remote destinations for # Style objects using link annotations. Typical use cases would be linking to a (named) # destination on a different page or executing a URI action. # # See: PDF2.0 s12.5.6.5, Layers, HexaPDF::Type::Annotations::Link class LinkLayer # Creates a new LinkLayer object. # # The following arguments are allowed (note that only *one* of +dest+, +uri+, +file+ or # +action+ may be specified): # # +dest+:: # The destination array or a name of a named destination for in-document links. If neither # +dest+, +uri+, +file+ nor +action+ is specified, it is assumed that the box has a custom # property named 'link' which is used for the destination. # # +uri+:: # The URI to link to. # # +file+:: # The file that should be opened or, if it refers to an application, the application that # should be launched. Can either be a string or a Filespec object. Also see: # HexaPDF::Type::FileSpecification. # # +action+:: # The PDF action that should be executed. # # +border+:: # If set to +true+, a standard border is used. Also accepts an array that adheres to the # rules for annotation borders. # # +border_color+:: # Defines the border color. Can be an array with 0 (transparent), 1 (grayscale), 3 (RGB) # or 4 (CMYK) values. # # Examples: # LinkLayer.new(dest: [page, :XYZ, nil, nil, nil], border: true) # LinkLayer.new(uri: "https://my.example.com/path", border: [5 5 2]) # LinkLayer.new # use 'link' custom box property for dest def initialize(dest: nil, uri: nil, file: nil, action: nil, border: false, border_color: nil) if dest && (uri || file || action) || uri && (file || action) || file && action raise ArgumentError, "Only one of dest, uri, file or action is allowed" end @dest = dest @action = if uri {S: :URI, URI: uri} elsif file {S: :Launch, F: file, NewWindow: true} elsif action action end @border = case border when false then [0, 0, 0] when true then nil when Array then border else raise ArgumentError, "Invalid value for border: #{border}" end @border_color = border_color end # Creates the needed link annotation if possible, i.e. if the context of the canvas is a # page. def call(canvas, box) return unless canvas.context.type == :Page @dest = box.properties['link'] unless @dest || @action page = canvas.context matrix = canvas.graphics_state.ctm quad_points = [*matrix.evaluate(0, 0), *matrix.evaluate(box.width, 0), *matrix.evaluate(box.width, box.height), *matrix.evaluate(0, box.height)] x_minmax = quad_points.values_at(0, 2, 4, 6).minmax y_minmax = quad_points.values_at(1, 3, 5, 7).minmax border_color = case @border_color when [], nil @border_color else canvas.color_from_specification(@border_color).components end annot = { Subtype: :Link, Rect: [x_minmax[0], y_minmax[0], x_minmax[1], y_minmax[1]], QuadPoints: quad_points, Dest: @dest, A: @action, Border: @border, C: border_color, } (page[:Annots] ||= []) << page.document.add(annot) end end UNSET = ::Object.new # :nodoc: # :call-seq: # Style.create(style) -> style # Style.create(properties_hash) -> style # # Creates a Style object based on the +style+ argument and returns it: # # * If +style+ is already a Style object, it is just returned. # # * If +style+ is a hash, a new Style object with the style properties specified by the hash # * is created. # # * If +style+ is +nil+, a new Style object with only default values is created. def self.create(style) case style when self then style when Hash then new(**style) when nil then new else raise ArgumentError, "Invalid argument class #{style.class}" end end # Creates a new Style object. # # The +properties+ hash may be used to set the initial values of properties by using keys # equivalent to the property names. # # Example: # Style.new(font_size: 15, text_align: :center, text_valign: center) def initialize(**properties) update(**properties) @scaled_item_widths = {}.compare_by_identity end # Duplicates the complex properties that can be modified, as well as the cache. def initialize_copy(other) super @scaled_item_widths = {}.compare_by_identity clear_cache @font_features = @font_features.dup if defined?(@font_features) @padding = @padding.dup if defined?(@padding) @margin = @margin.dup if defined?(@margin) @border = @border.dup if defined?(@border) @overlays = @overlays.dup if defined?(@overlays) @underlays = @underlays.dup if defined?(@underlays) end # :call-seq: # style.update(**properties) -> style # # Updates the style's properties using the key-value pairs specified by the +properties+ hash. def update(**properties) properties.each {|key, value| send(key, value) } self end ## # :method: font # :call-seq: # font(name = nil) # # The font to be used, must be set to a valid font wrapper object before it can be used. # # HexaPDF::Composer handles this property specially in that it resolves a set string or array # to a font wrapper object before doing anything else with the style object. # # This is the only style property without a default value! # # See: HexaPDF::Content::Canvas#font # # Examples: # # #>pdf-composer100 # composer.text("Helvetica", font: composer.document.fonts.add("Helvetica")) # composer.text("Courier", font: "Courier") # # helvetica_bold = composer.document.fonts.add("Helvetica", variant: :bold) # composer.text("Helvetica Bold", font: helvetica_bold) # composer.text("Courier Bold", font: "Courier bold") # composer.text("Courier Bold also", font: ["Courier", variant: :bold]) ## # :method: font_size # :call-seq: # font_size(size = nil) # # The font size, defaults to 10. # # See: HexaPDF::Content::Canvas#font_size # # Examples: # # #>pdf-composer100 # composer.text("Default size") # composer.text("Larger size", font_size: 20) ## # :method: line_height # :call-seq: # line_height(size = nil) # # The font size used for line height calculations, default is +nil+ meaing it defaults to # #font_size. # # This value should never be smaller than the font size since this would lead to overlapping # text. # # Examples: # # #>pdf-composer100 # composer.text("Line 1") # composer.text("Larger line height", line_height: 30) # composer.text("Line 3") ## # :method: character_spacing # :call-seq: # character_spacing(amount = nil) # # The character spacing, defaults to 0 (i.e. no additional character spacing). # # See: HexaPDF::Content::Canvas#character_spacing # # Examples: # # #>pdf-composer100 # composer.text("More spacing between characters", character_spacing: 1) ## # :method: word_spacing # :call-seq: # word_spacing(amount = nil) # # The word spacing, defaults to 0 (i.e. no additional word spacing). # # See: HexaPDF::Content::Canvas#word_spacing # # Examples: # # #>pdf-composer100 # composer.text("More word spacing", word_spacing: 20) ## # :method: horizontal_scaling # :call-seq: # horizontal_scaling(percent = nil) # # The horizontal scaling, defaults to 100 (in percent, i.e. normal scaling). # # See: HexaPDF::Content::Canvas#horizontal_scaling # # Examples: # # #>pdf-composer100 # composer.text("Horizontal scaling", horizontal_scaling: 150) ## # :method: text_rise # :call-seq: # text_rise(amount = nil) # # The text rise, i.e. the vertical offset from the baseline, defaults to 0. # # See: HexaPDF::Content::Canvas#text_rise # # Examples: # # #>pdf-composer100 # composer.formatted_text(["Normal", {text: "Up in the air", text_rise: 5}]) ## # :method: font_features # :call-seq: # font_features(features = nil) # # The font features (e.g. kerning, ligatures, ...) that should be applied by the shaping # engine, defaults to {} (i.e. no font features are applied). # # Each feature to be applied is indicated by a key with a truthy value. # # See: HexaPDF::Layout::TextShaper#shape_text for available features. # # Examples: # # #>pdf-composer100 # composer.style(:base, font: ["Times", custom_encoding: true], font_size: 30) # composer.text("Test flight") # composer.text("Test flight", font_features: {kern: true, liga: true}) ## # :method: text_rendering_mode # :call-seq: # text_rendering_mode(mode = nil) # # The text rendering mode, i.e. whether text should be filled, stroked, clipped, invisible or # a combination thereof, defaults to :fill. The returned value is always a normalized text # rendering mode value. # # See: HexaPDF::Content::Canvas#text_rendering_mode # # Examples: # # #>pdf-composer100 # composer.text("Test flight", font_size: 40, text_rendering_mode: :stroke) ## # :method: subscript # :call-seq: # subscript(enable = false) # # Render the text as subscript, i.e. lower and in a smaller font size; defaults to false. # # If superscript is set, it will be deactivated. # # Examples: # # #>pdf-composer100 # composer.formatted_text(["Some ", {text: "subscript text", subscript: true}]) ## # :method: superscript # :call-seq: # superscript(enable = false) # # Render the text as superscript, i.e. higher and in a smaller font size; defaults to false. # # If subscript is set, it will be deactivated. # # Examples: # # #>pdf-composer100 # composer.formatted_text(["Some ", {text: "superscript text", superscript: true}]) ## # :method: underline # :call-seq: # underline(enable = false) # # Renders a line underneath the text; defaults to false. # # Examples: # # #>pdf-composer100 # composer.text("Underlined text", underline: true) ## # :method: strikeout # :call-seq: # strikeout(enable = false) # # Renders a line through the text; defaults to false. # # Examples: # # #>pdf-composer100 # composer.text("Strikeout text", strikeout: true) ## # :method: fill_color # :call-seq: # fill_color(color = nil) # # The color used for filling (e.g. text), defaults to black. # # See: HexaPDF::Content::Canvas#fill_color # # Examples: # # #>pdf-composer100 # composer.text("This is some red text", fill_color: "red") ## # :method: fill_alpha # :call-seq: # fill_alpha(alpha = nil) # # The alpha value applied to filling operations (e.g. text), defaults to 1 (i.e. 100% # opaque). # # See: HexaPDF::Content::Canvas#opacity # # Examples: # # #>pdf-composer100 # composer.text("This is some semi-transparent text", fill_alpha: 0.5) ## # :method: stroke_color # :call-seq: # stroke_color(color = nil) # # The color used for stroking (e.g. text outlines), defaults to black. # # See: HexaPDF::Content::Canvas#stroke_color # # Examples: # # #>pdf-composer100 # composer.text("Stroked text", font_size: 40, stroke_color: "red", # text_rendering_mode: :stroke) ## # :method: stroke_alpha # :call-seq: # stroke_alpha(alpha = nil) # # The alpha value applied to stroking operations (e.g. text outlines), defaults to 1 (i.e. # 100% opaque). # # See: HexaPDF::Content::Canvas#opacity # # Examples: # # #>pdf-composer100 # composer.text("Stroked text", font_size: 40, stroke_alpha: 0.5, # text_rendering_mode: :stroke) ## # :method: stroke_width # :call-seq: # stroke_width(width = nil) # # The line width used for stroking operations (e.g. text outlines), defaults to 1. # # See: HexaPDF::Content::Canvas#line_width # # Examples: # # #>pdf-composer100 # composer.text("Stroked text", font_size: 40, stroke_width: 2, # text_rendering_mode: :stroke) ## # :method: stroke_cap_style # :call-seq: # stroke_cap_style(style = nil) # # The line cap style used for stroking operations (e.g. text outlines), defaults to :butt. The # returned values is always a normalized line cap style value. # # See: HexaPDF::Content::Canvas#line_cap_style # # Examples: # # #>pdf-composer100 # composer.text("Stroked text", font_size: 40, stroke_cap_style: :round, # text_rendering_mode: :stroke) ## # :method: stroke_join_style # :call-seq: # stroke_join_style(style = nil) # # The line join style used for stroking operations (e.g. text outlines), defaults to :miter. # The returned values is always a normalized line joine style value. # # See: HexaPDF::Content::Canvas#line_join_style # # Examples: # # #>pdf-composer100 # composer.text("Stroked text", font_size: 40, stroke_join_style: :bevel, # text_rendering_mode: :stroke) ## # :method: stroke_miter_limit # :call-seq: # stroke_miter_limit(limit = nil) # # The miter limit used for stroking operations (e.g. text outlines) when #stroke_join_style is # :miter, defaults to 10.0. # # See: HexaPDF::Content::Canvas#miter_limit # # Examples: # # #>pdf-composer100 # composer.text("Stroked text", font_size: 40, stroke_join_style: :bevel, # stroke_miter_limit: 1, text_rendering_mode: :stroke) ## # :method: stroke_dash_pattern # :call-seq: # stroke_dash_pattern(pattern = nil) # # The line dash pattern used for stroking operations (e.g. text outlines), defaults to a solid # line. # # See: HexaPDF::Content::Canvas#line_dash_pattern # # Examples: # # #>pdf-composer100 # composer.text("Stroked text", font_size: 40, stroke_dash_pattern: [4, 2], # text_rendering_mode: :stroke) ## # :method: text_align # :call-seq: # text_align(direction = nil) # # The horizontal alignment of text, defaults to :left. # # Possible values: # # :left:: Left-align the text, i.e. the right side is rugged. # :center:: Center the text horizontally. # :right:: Right-align the text, i.e. the left side is rugged. # :justify:: Justify the text, except for those lines that end in a hard line break. # # Examples: # # #>pdf-composer100 # text = "Lorem ipsum dolor sit amet. " * 2 # composer.style(:base, border: {width: 1}) # composer.text(text, text_align: :left) # composer.text(text, text_align: :center) # composer.text(text, text_align: :right) # composer.text(text, text_align: :justify) ## # :method: text_valign # :call-seq: # text_valign(direction = nil) # # The vertical alignment of items (normally text) inside a text box, defaults to :top. # # For :center and :bottom alignment the box will fill the whole available height. If this is # not wanted, an explicit height will need to be set for the box. # # This property is ignored when using position :flow for a text box. # # Possible values: # # :top:: Vertically align the items to the top of the box. # :center:: Vertically align the items in the center of the box. # :bottom:: Vertically align the items to the bottom of the box. # # Examples: # # #>pdf-composer100 # composer.style(:base, border: {width: 1}) # composer.text("Top aligned", height: 20, text_valign: :top) # composer.text("Center aligned", height: 20, text_valign: :center) # composer.text("Bottom aligned", text_valign: :bottom) ## # :method: text_indent # :call-seq: # text_indent(amount = nil) # # The indentation to be used for the first line of a sequence of text lines, defaults to 0. # # Examples: # # #>pdf-composer100 # composer.text("This is some longer text that wraps around in two lines.", # text_indent: 10) ## # :method: line_spacing # :call-seq: # line_spacing(type = nil, value = nil) # line_spacing(type:, value: 1) # # The type of line spacing to be used for text lines, defaults to type :single. # # This method can set the line spacing in two ways: # # * Using two positional arguments +type+ and +value+. # * Or a hash with the keys +type+ and +value+. # # Note that the last line has no additional spacing after it by default. Set #last_line_gap # for adding such a spacing. # # See LineSpacing for supported types of line spacing. # # Examples: # # #>pdf-composer100 # composer.text("This is some longer text that wraps around in two lines.", # line_spacing: 1.5) # composer.text("This is some longer text that wraps around in two lines.", # line_spacing: :double) # composer.text("This is some longer text that wraps around in two lines.", # line_spacing: {type: :proportional, value: 1.2}) ## # :method: last_line_gap # :call-seq: # last_line_gap(enable = false) # # Add an appropriately sized gap after the last line of text if enabled, defaults to false. # # Examples: # # #>pdf-composer100 # composer.text("This is some longer text that wraps around in two lines.", # line_spacing: 1.5, last_line_gap: true) # composer.text("There is spacing above this line due to last_line_gap.") ## # :method: fill_horizontal # :call-seq: # fill_horizontal(factor = nil) # # If set to a positive number, it specifies that the content of the text item should be # repeated and appropriate spacing applied so that the remaining space of the line is # completely filled. # # If there are multiple text items with this property set for a single line, the remaining # space is split between those items using the set +factors+. For example, if item A has a # factor of 1 and item B a factor of 2, the remaining space will be split so that item # B will receive twice the space of A. # # Notes: # # * This property _must not_ be applied to inline boxes, it only works for text items. # * If the filling should be done with spaces, the non-breaking space character \u{00a0} has # to be used. # # Examples: # # #>pdf-composer100 # composer.formatted_text(["Left", {text: "\u{00a0}", fill_horizontal: 1}, # "Right"]) # composer.formatted_text(["Typical table of contents entry", # {text: ".", fill_horizontal: 1}, "34"]) # composer.formatted_text(["Factor 1", {text: "\u{00a0}", fill_horizontal: 1}, # "Factor 3", {text: "\u{00a0}", fill_horizontal: 3}, "End"]) # overlays = [proc {|c, b| c.line(0, b.height / 2.0, b.width, b.height / 2.0).stroke}] # composer.formatted_text([{text: "\u{00a0}", fill_horizontal: 1, overlays: overlays}, # 'Centered', # {text: "\u{00a0}", fill_horizontal: 1, overlays: overlays}]) ## # :method: background_color # :call-seq: # background_color(color = nil) # # The color used for backgrounds, defaults to +nil+ (i.e. no background). # # Examples: # # #>pdf-composer100 # composer.text("Some text here", background_color: "lightgrey") ## # :method: background_alpha # :call-seq: # background_alpha(alpha = nil) # # The alpha value applied to the background when it is colored, defaults to 1 (i.e. 100% # opaque). # # See: HexaPDF::Content::Canvas#opacity # # Examples: # # #>pdf-composer100 # composer.text("Some text here", background_color: "red", background_alpha: 0.5) ## # :method: padding # :call-seq: # padding(value = nil) # # The padding between the border and the contents, defaults to 0 for all four sides. # # See Style::Quad#set for information on how to set the values. # # Examples: # # #>pdf-composer100 # composer.text("Some text here", padding: 10, border: {width: 1}) ## # :method: margin # :call-seq: # margin(value = nil) # # The margin around a box, defaults to 0 for all four sides. # # See Style::Quad#set for information on how to set the values. # # Examples: # # #>pdf-composer100 # composer.text("Some text here", margin: [5, 10], position: :float, # border: {width: 1}) # composer.text("Text starts after floating box and continues below it, " \ # "respecting the margin.", position: :flow) ## # :method: border # :call-seq: # border(value = nil) # # The border around the contents, defaults to no border for all four sides. # # The value has to be a hash containing any of the keys :width, :color and :style. The width, # color and style of the border can be set independently for each side (see Style::Quad#set). # # See Border for more details. # # Examples: # # #>pdf-composer100 # composer.text("Some text here", border: { # width: [6, 3], # color: ["green", "blue", "orange"], # style: [:solid, :dashed] # }) ## # :method: overlays # :call-seq: # overlays(layers = nil) # # A Style::Layers object containing all the layers that should be drawn over the box; defaults # to no layers being drawn. # # The +layers+ argument needs to be an array of layer objects. To define a layer either use a # callable object taking the canvas and the box as arguments; or use a pre-defined layer using # an array of the form [:layer_name, **options]. See Style::Layers for details. # # Examples: # # #>pdf-composer100 # composer.text("Some text here", overlays: [ # lambda do |canvas, box| # canvas.stroke_color("red").opacity(stroke_alpha: 0.5). # line_width(5).line(0, 0, box.width, box.height).stroke # end, # [:link, uri: "https://hexapdf.gettalong.org"] # ]) ## # :method: underlays # :call-seq: # underlays(layers = nil) # # A Style::Layers object containing all the layers that should be drawn under the box; # defaults to no layers being drawn. # # The +layers+ argument needs to be an array of layer objects. To define a layer either use a # callable object taking the canvas and the box as arguments; or use a pre-defined layer using # an array of the form [:layer_name, **options]. See Style::Layers for details. # # Examples: # # #>pdf-composer100 # composer.text("Some text here", underlays: [ # lambda do |canvas, box| # canvas.stroke_color("red").opacity(stroke_alpha: 0.5). # line_width(5).line(0, 0, box.width, box.height).stroke # end, # [:link, uri: "https://hexapdf.gettalong.org"] # ]) ## # :method: position # :call-seq: # position(value = nil) # # Specifies how a box should be positioned in a frame. Defaults to :default. # # The properties #align and #valign provide alignment information while #mask_mode defines how # the to-be-removed region should be constructed. # # Possible values: # # :default:: # Position the box at the current position. The exact horizontal and vertical position # inside the current region is given via the #align and #valign style properties. # # Examples: # # #>pdf-composer100 # composer.box(:base, width: 40, height: 20, # style: {align: :right, border: {width: 1}}) # composer.box(:base, width: 40, height: 20, # style: {align: :center, valign: :center, border: {width: 1}}) # # :float:: # This is the same as :default except that the used value for #mask_mode when it is set to # :default is :box instead of :fill_frame_horizontal. # # Examples: # # #>pdf-composer100 # composer.box(:base, width: 40, height: 20, # style: {position: :float, border: {width: 1}}) # composer.box(:base, width: 40, height: 20, # style: {position: :float, border: {color: "hp-blue", width: 1}}) # # :flow:: # Flows the content of the box inside the frame around objects. # # A box needs to indicate whether it supports this value by implementing the # #supports_position_flow? method and returning +true+ if it does or +false+ if it # doesn't. If a box doesn't support this value, it is positioned as if the value :default # was set. # # Note that the properties #align and #valign are not used with this value! # # Examples: # # #>pdf-composer100 # composer.box(:base, width: 40, height: 20, # style: {position: :float, border: {width: 1}}) # composer.lorem_ipsum(position: :flow) # # [x, y]:: # Position the box with the bottom left corner at the given absolute position relative to # the bottom left corner of the frame. # # Examples: # # #>pdf-composer100 # composer.text('Absolute', position: [50, 50], border: {width: 1}) # draw_current_frame_shape("red") ## # :method: align # :call-seq: # align(value = nil) # # Specifies the horizontal alignment of a box inside the current region. Defaults to :left. # # Possible values: # # :left:: Align the box to the left side of the current region. # :center:: Horizontally center the box in the current region. # :right:: Align the box to the right side of the current region. # # Examples: # # #>pdf-composer100 # composer.text("Left", border: {width: 1}) # draw_current_frame_shape("hp-blue") # composer.text("Center", align: :center, border: {width: 1}) # draw_current_frame_shape("hp-orange") # composer.text("Right", align: :right, border: {width: 1}) # draw_current_frame_shape("hp-teal") ## # :method: valign # :call-seq: # valign(value = nil) # # Specifies the vertical alignment of a box inside the current region. Defaults to :top. # # Possible values: # # :top:: Align the box to the top side of the current region. # :center:: Vertically center the box in the current region. # :bottom:: Align the box to the bottom side of the current region. # # Examples: # # #>pdf-composer100 # composer.text("Top", mask_mode: :fill_vertical, border: {width: 1}) # composer.text("Center", valign: :center, mask_mode: :fill_vertical, border: {width: 1}) # composer.text("Bottom", valign: :bottom, border: {width: 1}) ## # :method: mask_mode # :call-seq: # mask_mode(value = nil) # # Specifies how the mask defining the to-be-removed region should be constructed. Defaults to # :default. # # Possible values: # # :default:: # The actually used value depends on the value of #position: # # * For :default the used value is :fill_frame_horizontal. # * For :float the used value is :box. # * For :flow the used value is :fill_frame_horizontal. # * For :absolute the used value is :box. # # :none:: # The mask covers nothing (useful for layering boxes over each other). # # Examples: # # #>pdf-composer100 # composer.text('Text on bottom', mask_mode: :none) # composer.text('Text on top', fill_color: 'hp-blue') # # :box:: # The mask covers the box including the margin around the box. # # Examples: # # #>pdf-composer100 # composer.text('Box only mask', mask_mode: :box) # draw_current_frame_shape('hp-blue') # composer.text('Text to the right') # # :fill_horizontal:: # The mask covers the box including the margin around the box and the space to the left # and right in the current region. # # Examples: # # #>pdf-composer100 # composer.text('Standard, whole horizontal space') # draw_current_frame_shape('hp-blue') # composer.text('Text underneath') # # :fill_frame_horizontal:: # The mask covers the box including the margin around the box and the space to the left # and right in the frame. # # Examples: # # #>pdf-composer100 # composer.frame.remove_area(Geom2D::Rectangle(100, 50, 10, 50)) # composer.text('Mask covers frame horizontally', mask_mode: :fill_frame_horizontal) # draw_current_frame_shape('hp-blue') # composer.text('Text underneath') # # :fill_vertical:: # The mask covers the box including the margin around the box and the space to the top # and bottom in the current region. # # Examples: # # #>pdf-composer100 # composer.text('Mask covers vertical space', mask_mode: :fill_vertical) # draw_current_frame_shape('hp-blue') # composer.text('Text to the right') # # :fill:: # The mask covers the current region completely. # # Examples: # # #>pdf-composer100 # composer.text('Mask covers everything', mask_mode: :fill) # composer.text('On the next page') ## # :method: overflow # :call-seq: # overflow(mode = nil) # # Specifies how overflowing boxes (e.g. the text of a box or the children of a container) with # a given initial height should be handled: # # Possible values: # # :error:: An error is raised (default). # :truncate:: Truncates the overflowing parts. # # Examples: # # #>pdf-composer100 # composer.text("This is some longer text that does appear in two lines.") # composer.text("This is some longer text that does not appear in two lines.", # height: 15, overflow: :truncate) [ [:font, "raise HexaPDF::Error, 'No font set'"], [:font_size, 10], [:line_height, nil], [:character_spacing, 0], [:word_spacing, 0], [:horizontal_scaling, 100], [:text_rise, 0], [:font_features, {}], [:text_rendering_mode, "Content::TextRenderingMode::FILL", {setter: "Content::TextRenderingMode.normalize(value)"}], [:subscript, false, {setter: "value; superscript(false) if superscript", valid_values: [true, false]}], [:superscript, false, {setter: "value; subscript(false) if subscript", valid_values: [true, false]}], [:underline, false, {valid_values: [true, false]}], [:strikeout, false, {valid_values: [true, false]}], [:fill_color, "default_color"], [:fill_alpha, 1], [:stroke_color, "default_color"], [:stroke_alpha, 1], [:stroke_width, 1], [:stroke_cap_style, "Content::LineCapStyle::BUTT_CAP", {setter: "Content::LineCapStyle.normalize(value)"}], [:stroke_join_style, "Content::LineJoinStyle::MITER_JOIN", {setter: "Content::LineJoinStyle.normalize(value)"}], [:stroke_miter_limit, 10.0], [:stroke_dash_pattern, "Content::LineDashPattern.new", {setter: "Content::LineDashPattern.normalize(value, phase)", extra_args: ", phase = 0"}], [:text_align, :left, {valid_values: [:left, :center, :right, :justify]}], [:text_valign, :top, {valid_values: [:top, :center, :bottom]}], [:text_indent, 0], [:line_spacing, "LineSpacing.new(type: :single)", {setter: "LineSpacing.new(**(value.kind_of?(Symbol) || value.kind_of?(Numeric) ? " \ "{type: value, value: extra_arg} : value))", extra_args: ", extra_arg = nil"}], [:last_line_gap, false, {valid_values: [true, false]}], [:fill_horizontal, nil], [:background_color, nil], [:background_alpha, 1], [:padding, "Quad.new(0)", {setter: "Quad.new(value)"}], [:margin, "Quad.new(0)", {setter: "Quad.new(value)"}], [:border, "Border.new", {setter: "Border.new(**value)"}], [:overlays, "Layers.new", {setter: "Layers.new(value)"}], [:underlays, "Layers.new", {setter: "Layers.new(value)"}], [:position, :default], [:align, :left, {valid_values: [:left, :center, :right]}], [:valign, :top, {valid_values: [:top, :center, :bottom]}], [:mask_mode, :default, {valid_values: [:default, :none, :box, :fill_horizontal, :fill_frame_horizontal, :fill_vertical, :fill]}], [:overflow, :error], ].each do |name, default, options = {}| default = default.inspect unless default.kind_of?(String) setter = options.delete(:setter) || "value" extra_args = options.delete(:extra_args) || "" valid_values = options.delete(:valid_values) raise ArgumentError, "Invalid keywords: #{options.keys.join(', ')}" unless options.empty? valid_values_const = "#{name}_valid_values".upcase const_set(valid_values_const, valid_values) module_eval(<<-EOF, __FILE__, __LINE__ + 1) def #{name}(value = UNSET#{extra_args}) if value == UNSET @#{name} ||= #{default} elsif #{valid_values_const} && !#{valid_values_const}.include?(value) raise ArgumentError, "\#{value.inspect} is not a valid #{name} value " \\ "(\#{#{valid_values_const}.map(&:inspect).join(', ')})" else @#{name} = #{setter} self end end def #{name}? defined?(@#{name}) end EOF alias_method("#{name}=", name) end ## # :method: text_segmentation_algorithm # :call-seq: # text_segmentation_algorithm(algorithm = nil) {|items| block } # # The algorithm to use for text segmentation purposes, defaults to # TextLayouter::SimpleTextSegmentation. # # When setting the algorithm, either an object that responds to #call(items) or a block can be # used. ## # :method: text_line_wrapping_algorithm # :call-seq: # text_line_wrapping_algorithm(algorithm = nil) {|items, width_block| block } # # The line wrapping algorithm that should be used, defaults to # TextLayouter::SimpleLineWrapping. # # When setting the algorithm, either an object that responds to #call or a block can be used. # See TextLayouter::SimpleLineWrapping#call for the needed method signature. [ [:text_segmentation_algorithm, 'TextLayouter::SimpleTextSegmentation'], [:text_line_wrapping_algorithm, 'TextLayouter::SimpleLineWrapping'], ].each do |name, default| default = default.inspect unless default.kind_of?(String) module_eval(<<-EOF, __FILE__, __LINE__ + 1) def #{name}(value = UNSET, &block) if value == UNSET && !block @#{name} ||= #{default} else @#{name} = (value != UNSET ? value : block) self end end def #{name}? defined?(@#{name}) end EOF alias_method("#{name}=", name) end # The calculated text rise, taking superscript and subscript into account. def calculated_text_rise if superscript text_rise + font_size * 0.33 elsif subscript text_rise - font_size * 0.20 else text_rise end end # The calculated font size, taking superscript and subscript into account. def calculated_font_size (superscript || subscript ? 0.583 : 1) * font_size end # Returns the correct offset from the baseline for the underline. def calculated_underline_position calculated_text_rise + font.wrapped_font.underline_position * font.scaling_factor * font.pdf_object.glyph_scaling_factor * calculated_font_size - calculated_underline_thickness / 2.0 end # Returns the correct thickness for the underline. def calculated_underline_thickness font.wrapped_font.underline_thickness * font.scaling_factor * font.pdf_object.glyph_scaling_factor * calculated_font_size end # Returns the correct offset from the baseline for the strikeout line. def calculated_strikeout_position calculated_text_rise + font.wrapped_font.strikeout_position * font.scaling_factor * font.pdf_object.glyph_scaling_factor * calculated_font_size - calculated_strikeout_thickness / 2.0 end # Returns the correct thickness for the strikeout line. def calculated_strikeout_thickness font.wrapped_font.strikeout_thickness * font.scaling_factor * font.pdf_object.glyph_scaling_factor * calculated_font_size end # The font size scaled appropriately. def scaled_font_size @scaled_font_size ||= calculated_font_size * font.pdf_object.glyph_scaling_factor * scaled_horizontal_scaling end # The character spacing scaled appropriately. def scaled_character_spacing @scaled_character_spacing ||= character_spacing * scaled_horizontal_scaling end # The word spacing scaled appropriately. def scaled_word_spacing @scaled_word_spacing ||= word_spacing * scaled_horizontal_scaling end # The horizontal scaling scaled appropriately. def scaled_horizontal_scaling @scaled_horizontal_scaling ||= horizontal_scaling / 100.0 end # The ascender of the font scaled appropriately. def scaled_font_ascender @scaled_font_ascender ||= font.wrapped_font.ascender * font.scaling_factor * font.pdf_object.glyph_scaling_factor * font_size end # The descender of the font scaled appropriately. def scaled_font_descender @scaled_font_descender ||= font.wrapped_font.descender * font.scaling_factor * font.pdf_object.glyph_scaling_factor * font_size end # The minimum y-coordinate, calculated using the scaled descender of the font and the line # height or font size. def scaled_y_min @scaled_y_min ||= scaled_font_descender * (line_height || font_size) / font_size.to_f + calculated_text_rise end # The maximum y-coordinate, calculated using the scaled ascender of the font and the line # height or font size. def scaled_y_max @scaled_y_max ||= scaled_font_ascender * (line_height || font_size) / font_size.to_f + calculated_text_rise end # Returns the width of the item scaled appropriately (by taking font size, characters spacing, # word spacing and horizontal scaling into account). # # The item may be a (singleton) glyph object or an integer/float, i.e. items that can appear # inside a TextFragment. def scaled_item_width(item) @scaled_item_widths[item] ||= if item.kind_of?(Numeric) -item * scaled_font_size else item.width * scaled_font_size + scaled_character_spacing + (item.apply_word_spacing? ? scaled_word_spacing : 0) end end # Clears all cached values. # # This method needs to be called if the following style properties are changed and values were # already cached: font, font_size, character_spacing, word_spacing, horizontal_scaling, # ascender, descender. def clear_cache @scaled_font_size = @scaled_character_spacing = @scaled_word_spacing = nil @scaled_horizontal_scaling = @scaled_font_ascender = @scaled_font_descender = nil @scaled_y_min = @scaled_y_max = nil @scaled_item_widths.clear end private # Returns the default color for an empty PDF page, i.e. black. def default_color GlobalConfiguration.constantize('color_space.map', :DeviceGray).new.default_color end end end end