# -*- 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-2019 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/document' require 'hexapdf/layout' module HexaPDF # The composer class can be used to create PDF documents from scratch. It uses Frame and Box # objects underneath. # # == Usage # # First, a new Composer objects needs to be created, either using ::new or the utility method # ::create. # # On creation a HexaPDF::Document object is created as well the first page and an accompanying # HexaPDF::Layout::Frame object. The frame is used by the various methods for general document # layout tasks, like positioning of text, images, and so on. By default, it covers the whole page # except the margin area. How the frame gets created can be customized by overriding the # #create_frame method. # # Once the Composer object is created, its methods can be used to draw text, images, ... on the # page. Behind the scenes HexaPDF::Layout::Box (and subclass) objects are created and drawn on the # page via the frame. # # The base style that is used by all these boxes can be defined using the #base_style method which # returns a HexaPDF::Layout::Style object. The only style property that is set by default is the # font (Times) because otherwise there would be problems with text drawing operations (font is the # only style property that has no valid default value). # # If the frame of a page is full and a box doesn't fit anymore, a new page is automatically # created. The box is either split into two boxes where one fits on the first page and the other # on the new page, or it is drawn completely on the new page. A new page can also be created by # calling the #new_page method. # # The #x and #y methods provide the point where the next box would be drawn if it fits the # available space. This information can be used, for example, for custom drawing operations # through #canvas which provides direct access to the HexaPDF::Content::Canvas object of the # current page. # # When using #canvas and modifying the graphics state, care has to be taken to avoid problems with # later box drawing operations since the graphics state cannot completely be reset (e.g. # transformations of the canvas cannot always be undone). So it is best to save the graphics state # before and restore it afterwards. # # == Example # # HexaPDF::Composer.create('output.pdf', margin: 36) do |pdf| # pdf.base_style.font_size(20).align(:center) # pdf.text("Hello World", valign: :center) # end class Composer # Creates a new PDF document and writes it to +output+. The +options+ are passed to ::new. # # Example: # # HexaPDF::Composer.create('output.pdf', margin: 36) do |pdf| # ... # end def self.create(output, **options, &block) new(**options, &block).write(output) end # The PDF document that is created. attr_reader :document # The current page (a HexaPDF::Type::Page object). attr_reader :page # The Content::Canvas of the current page. Can be used to perform arbitrary drawing operations. attr_reader :canvas # The Layout::Frame for automatic box placement. attr_reader :frame # The base style which is used when no explicit style is provided to methods (e.g. to #text). attr_reader :base_style # Creates a new Composer object and optionally yields it to the given block. # # page_size:: # Can be any valid predefined page size (see Type::Page::PAPER_SIZE) or an array [llx, lly, # urx, ury] specifying a custom page size. # # page_orientation:: # Specifies the orientation of the page, either +:portrait+ or +:landscape+. Only used if # +page_size+ is one of the predefined page sizes. # # margin:: # The margin to use. See Layout::Style::Quad#set for possible values. def initialize(page_size: :A4, page_orientation: :portrait, margin: 36) #:yields: composer @document = HexaPDF::Document.new @page_size = page_size @page_orientation = page_orientation @margin = Layout::Style::Quad.new(margin) new_page @base_style = Layout::Style.new(font: 'Times') yield(self) if block_given? end # Creates a new page, making it the current one. # # If any of +page_size+, +page_orientation+ or +margin+ are set, they will be used instead of # the default values and will become the default values. # # Examples: # # composer.new_page # uses the default values # composer.new_page(page_size: :A5, margin: [72, 36]) def new_page(page_size: nil, page_orientation: nil, margin: nil) @page_size = page_size if page_size @page_orientation = page_orientation if page_orientation @margin = Layout::Style::Quad.new(margin) if margin @page = @document.pages.add(@page_size, orientation: @page_orientation) @canvas = @page.canvas create_frame end # The x-position of the cursor inside the current frame. def x @frame.x end # The y-position of the cursor inside the current frame. def y @frame.y end # Writes the PDF document to the given output. # # See Document#write for details. def write(output, optimize: true, **options) @document.write(output, optimize: optimize, **options) end # Draws the given text at the current position into the current frame. # # This method is the main method for displaying text on a PDF page. It uses a Layout::TextBox # behind the scenes to do the actual work. # # The text will be positioned at the current position if possible. Otherwise the next best # position is used. If the text doesn't fit onto the current page or only partially, new pages # are created automatically. # # The arguments +width+ and +height+ are used as constraints and are respected when fitting the # box. # # The text is styled using the given +style+ object (see Layout::Style) or, if no style object # is specified, the base style (see #base_style). If any additional style +options+ are # specified, the used style is copied and the additional styles are applied. # # See HexaPDF::Layout::TextBox for details. def text(str, width: 0, height: 0, style: nil, **options) style = update_style(style, options) draw_box(Layout::TextBox.new([Layout::TextFragment.create(str, style)], width: width, height: height, style: style)) end # Draws text like #text but where parts of it can be formatted differently. # # The argument +data+ needs to be an array of String or Hash objects: # # * A String object is treated like {text: data}. # # * Hashes can contain any style properties and the following special keys: # # text:: The text to be formatted. # # link:: A URL that should be linked to. If no text is provided but a link, the link is used # as text. # # style:: A Layout::Style object to use as basis instead of the style created from the +style+ # and +options+ arguments. # # If any style properties are set, the used style is copied and the additional properties # applied. # # Examples: # # composer.formatted_text(["Some string"]) # The same as #text # composer.formatted_text(["Some ", {text: "string", fill_color: 128}] # composer.formatted_text(["Some ", {link: "https://example.com", text: "Example"}]) # composer.formatted_text(["Some ", {text: "string", style: my_style}]) def formatted_text(data, width: 0, height: 0, style: nil, **options) style = update_style(style, options) data.map! do |hash| if hash.kind_of?(String) Layout::TextFragment.create(hash, style) else link = hash.delete(:link) text = hash.delete(:text) || link || "" used_style = update_style(hash.delete(:style), options) || style if link || !hash.empty? used_style = used_style.dup hash.each {|key, value| used_style.send(key, value) } used_style.overlays.add(:link, uri: link) if link end Layout::TextFragment.create(text, used_style) end end draw_box(Layout::TextBox.new(data, width: width, height: height, style: style)) end # Draws the given image file at the current position. # # See #text for details on +width+, +height+, +style+ and +options+. def image(file, width: 0, height: 0, style: nil, **options) style = update_style(style, options) image = document.images.add(file) draw_box(Layout::ImageBox.new(image, width: width, height: height, style: style)) end # Draws the given Layout::Box. # # The box is drawn into the current frame if possible. If it doesn't fit, the box is split. If # it still doesn't fit, a new region of the frame is determined and then the process starts # again. # # If none or only some parts of the box fit into the current frame, one or more new pages are # created for the rest of the box. def draw_box(box) drawn_on_page = true while true if @frame.fit(box) @frame.draw(@canvas, box) break elsif @frame.full? new_page drawn_on_page = false else draw_box, box = @frame.split(box) if draw_box @frame.draw(@canvas, draw_box) drawn_on_page = true elsif !@frame.find_next_region unless drawn_on_page raise HexaPDF::Error, "Box doesn't fit on empty page" end new_page drawn_on_page = false end end end end private # Creates the frame into which boxes are layed out when a new page is created. def create_frame media_box = @page.box @frame = Layout::Frame.new(media_box.left + @margin.left, media_box.bottom + @margin.bottom, media_box.width - @margin.left - @margin.right, media_box.height - @margin.bottom - @margin.top) end # Updates the Layout::Style object +style+ if one is provided, or the base style, with the style # options to make it work in all cases. def update_style(style, options = {}) style ||= base_style style = style.dup.update(**options) unless options.empty? style.font(base_style.font) unless style.font? style.font(@document.fonts.add(style.font)) unless style.font.respond_to?(:dict) style end end end