require 'prawn' require 'prawn/table' module InvoicePrinter # Prawn PDF representation of InvoicePrinter::Document # # Example: # # invoice = InvoicePrinter::Document.new(...) # invoice_pdf = InvoicePrinter::PDFDocument.new( # document: invoice, # labels: {}, # font: 'font.ttf', # stamp: 'stamp.jpg', # logo: 'example.jpg' # ) class PDFDocument class FontFileNotFound < StandardError; end class LogoFileNotFound < StandardError; end class StampFileNotFound < StandardError; end class InvalidInput < StandardError; end attr_reader :invoice, :labels, :file_name, :font, :stamp, :logo DEFAULT_LABELS = { name: 'Invoice', provider: 'Provider', purchaser: 'Purchaser', tax_id: 'Identification number', tax_id2: 'Identification number', payment: 'Payment', payment_by_transfer: 'Payment by bank transfer on the account below:', payment_in_cash: 'Payment in cash', account_number: 'Account NO', swift: 'SWIFT', iban: 'IBAN', issue_date: 'Issue date', due_date: 'Due date', variable_symbol: 'Variable symbol', item: 'Item', variable: '', quantity: 'Quantity', unit: 'Unit', price_per_item: 'Price per item', tax: 'Tax', tax2: 'Tax 2', tax3: 'Tax 3', amount: 'Amount', subtotal: 'Subtotal', total: 'Total', sublabels: {} } PageSize = Struct.new(:name, :width, :height) PAGE_SIZES = { letter: PageSize.new('LETTER', 612.00, 792.00), a4: PageSize.new('A4', 595.28, 841.89), } def self.labels @@labels ||= DEFAULT_LABELS end def self.labels=(labels) @@labels = DEFAULT_LABELS.merge(labels) end def initialize(document: Document.new, labels: {}, font: nil, stamp: nil, logo: nil, background: nil, page_size: :letter) @document = document @labels = merge_custom_labels(labels) @font = font @stamp = stamp @logo = logo @page_size = page_size ? PAGE_SIZES[page_size.to_sym] : PAGE_SIZES[:letter] @pdf = Prawn::Document.new(background: background, page_size: @page_size.name) raise InvalidInput, 'document is not a type of InvoicePrinter::Document' \ unless @document.is_a?(InvoicePrinter::Document) if used? @logo if File.exist?(@logo) @logo = logo else raise LogoFileNotFound, "Logotype file not found at #{@logo}" end end if used? @stamp if File.exist?(@stamp) @stamp = stamp else raise StampFileNotFound, "Stamp file not found at #{@stamp}" end end if used? @font use_font(@font) end build_pdf end # Create PDF file named +file_name+ def print(file_name = 'invoice.pdf') @pdf.render_file file_name end # Directly render the PDF def render @pdf.render end private def use_font(font) if File.exist?(@font) set_font_from_path(@font) else set_builtin_font(@font) end end def set_builtin_font(font) require 'invoice_printer/fonts' @pdf.font_families.update( "#{font}" => InvoicePrinter::Fonts.paths_for(font) ) @pdf.font(font) rescue StandardError raise FontFileNotFound, "Font file not found for #{font}" end # Add font family in Prawn for a given +font+ file def set_font_from_path(font) font_name = Pathname.new(font).basename @pdf.font_families.update( "#{font_name}" => { normal: font, italic: font, bold: font, bold_italic: font } ) @pdf.font(font_name) end # Build the PDF version of the document (@pdf) def build_pdf @push_down = 0 @push_items_table = 0 @pdf.fill_color '000000' @pdf.stroke_color 'aaaaaa' build_header build_provider_box build_purchaser_box build_payment_method_box build_info_box build_items build_total build_stamp build_logo build_note build_footer end # Build the document name and number at the top: # # NAME NO. 901905374583579 # Sublabel name def build_header @pdf.text_box( @labels[:name], size: 20, align: :left, at: [0, y(720) - @push_down], width: x(300), ) if used? @labels[:sublabels][:name] @pdf.text_box( @labels[:sublabels][:name], size: 12, at: [0, y(720) - @push_down - 22], width: x(300), align: :left ) end @pdf.text_box( @document.number, size: 20, at: [x(240), y(720) - @push_down], width: x(300), align: :right ) @pdf.move_down(250) if used? @labels[:sublabels][:name] @pdf.move_down(12) end end # Build the following provider box: # # ------------------------------------------ # | Provider Optional provider sublabel| # | PROVIDER co. | # | 5th Street | # | 747 27 City | # | Part of the city | # | | # | Identification number: Number | # | Identification number: Number 2 | # ------------------------------------------ # def build_provider_box @pdf.text_box( @document.provider_name, size: 15, at: [10, y(640) - @push_down], width: x(220) ) @pdf.text_box( @labels[:provider], size: 11, at: [10, y(660) - @push_down], width: x(240) ) if used? @labels[:sublabels][:provider] @pdf.text_box( @labels[:sublabels][:provider], size: 10, at: [10, y(660) - @push_down], width: x(246), align: :right ) end # Render provider_lines if present if !@document.provider_lines.empty? lines = @document.provider_lines.split("\n") line_y = 618 lines.each_with_index do |line, index| next if index > 3 @pdf.text_box( "#{line}", size: 10, at: [10, y(line_y - index*15) - @push_down], width: x(240) ) end end unless @document.provider_tax_id.empty? @pdf.text_box( "#{@labels[:tax_id]}: #{@document.provider_tax_id}", size: 10, at: [10, y(550) - @push_down], width: x(240) ) end unless @document.provider_tax_id2.empty? @pdf.text_box( "#{@labels[:tax_id2]}: #{@document.provider_tax_id2}", size: 10, at: [10, y(535) - @push_down], width: x(240) ) end @pdf.stroke_rounded_rectangle([0, y(670) - @push_down], x(266), y(150), 6) end # Build the following purchaser box: # # ------------------------------------------- # | Purchaser Optinal purchaser sublabel| # | PURCHASER co. | # | 5th Street | # | 747 27 City | # | Part of the city | # | | # | Identification number: Number | # | Identification number: Number 2 | # ------------------------------------------ # def build_purchaser_box @pdf.text_box( @document.purchaser_name, size: 15, at: [x(284), y(640) - @push_down], width: x(240) ) @pdf.text_box( @labels[:purchaser], size: 11, at: [x(284), y(660) - @push_down], width: x(240) ) if used? @labels[:sublabels][:purchaser] @pdf.text_box( @labels[:sublabels][:purchaser], size: 10, at: [10, y(660) - @push_down], width: x(520), align: :right ) end # Render purchaser_lines if present if !@document.purchaser_lines.empty? lines = @document.purchaser_lines.split("\n") line_y = 618 lines.each_with_index do |line, index| next if index > 3 @pdf.text_box( "#{line}", size: 10, at: [x(284), y(line_y - index*15) - @push_down], width: x(240) ) end end unless @document.purchaser_tax_id.empty? @pdf.text_box( "#{@labels[:tax_id]}: #{@document.purchaser_tax_id}", size: 10, at: [x(284), y(550) - @push_down], width: x(240) ) end unless @document.purchaser_tax_id2.empty? @pdf.text_box( "#{@labels[:tax_id2]}: #{@document.purchaser_tax_id2}", size: 10, at: [x(284), y(535) - @push_down], width: x(240) ) end @pdf.stroke_rounded_rectangle([x(274), y(670) - @push_down], x(266), y(150), 6) end # Build the following payment box: # # ----------------------------------------- # | Payment on the following bank account: | # | Number: 3920392032| # | Optional number sublabel | # | SWIFT: xy| # | Optional SWIFT sublabel | # | IBAN: xy| # | Optional IBAN sublabel | # ----------------------------------------- # # If the bank account number is not provided include a note about payment # in cash. def build_payment_method_box @push_down -= 3 unless letter? @push_items_table += 18 end # Match the height of next box if needed min_height = 60 if used?(@document.issue_date) || used?(@document.due_date) min_height = (used?(@document.issue_date) && used?(@document.due_date)) ? 75 : 60 end @payment_box_height = min_height if big_info_box? @payment_box_height = 110 end if @document.bank_account_number.empty? @pdf.text_box( @labels[:payment], size: 10, at: [10, y(498) - @push_down], width: x(234) ) @pdf.text_box( @labels[:payment_in_cash], size: 10, at: [10, y(483) - @push_down], width: x(234) ) @pdf.stroke_rounded_rectangle([0, y(508) - @push_down], x(266), @payment_box_height, 6) else @payment_box_height = 60 @push_iban = 0 sublabel_change = 0 @pdf.text_box( @labels[:payment_by_transfer], size: 10, at: [10, y(498) - @push_down], width: x(234) ) @pdf.text_box( "#{@labels[:account_number]}", size: 11, at: [10, y(483) - @push_down], width: x(134) ) @pdf.text_box( @document.bank_account_number, size: 13, at: [21, y(483) - @push_down], width: x(234), align: :right ) if used? @labels[:sublabels][:account_number] @pdf.text_box( "#{@labels[:sublabels][:account_number]}", size: 10, at: [10, y(468) - @push_down], width: x(334) ) else @payment_box_height -= 10 sublabel_change -= 10 end unless @document.account_swift.empty? @pdf.text_box( "#{@labels[:swift]}", size: 11, at: [10, y(453) - @push_down - sublabel_change], width: x(134) ) @pdf.text_box( @document.account_swift, size: 13, at: [21, y(453) - @push_down - sublabel_change], width: x(234), align: :right ) if used? @labels[:sublabels][:swift] @pdf.text_box( "#{@labels[:sublabels][:swift]}", size: 10, at: [10, y(438) - @push_down - sublabel_change], width: x(334) ) @push_items_table += 10 else @payment_box_height -= 10 sublabel_change -= 10 end @payment_box_height += 30 @push_iban = 30 @push_items_table += 18 end unless @document.account_iban.empty? @pdf.text_box( "#{@labels[:iban]}", size: 11, at: [10, y(453) - @push_iban - @push_down - sublabel_change], width: x(134) ) @pdf.text_box( @document.account_iban, size: 13, at: [21, y(453) - @push_iban - @push_down - sublabel_change], width: x(234), align: :right ) if used? @labels[:sublabels][:iban] @pdf.text_box( "#{@labels[:sublabels][:iban]}", size: 10, at: [10, y(438) - @push_iban - @push_down - sublabel_change], width: x(334) ) @push_items_table += 10 else @payment_box_height -= 10 end @payment_box_height += 30 @push_items_table += 18 end if min_height > @payment_box_height @payment_box_height = min_height @push_items_table += 25 end if !@document.account_swift.empty? && !@document.account_iban.empty? @push_items_table += 2 end @pdf.stroke_rounded_rectangle([0, y(508) - @push_down], x(266), @payment_box_height, 6) end end # Build the following info box: # # -------------------------------- # | Issue date: 03/03/2016| # | Issue date sublabel | # | Due date: 03/03/2016| # | Due date sublabel | # | Variable symbol: VS12345678| # | Variable symbol sublabel | # -------------------------------- # def build_info_box issue_date_present = !@document.issue_date.empty? if issue_date_present @pdf.text_box( @labels[:issue_date], size: 11, at: [x(284), y(498) - @push_down], width: x(240) ) @pdf.text_box( @document.issue_date, size: 13, at: [x(384), y(498) - @push_down], width: x(146), align: :right ) if used? @labels[:sublabels][:issue_date] position = issue_date_present ? 483 : 498 @pdf.text_box( @labels[:sublabels][:issue_date], size: 10, at: [x(284), y(position) - @push_down], width: x(240) ) end end due_date_present = !@document.due_date.empty? if due_date_present position = issue_date_present ? 478 : 493 position -= 10 if used? @labels[:sublabels][:issue_date] @pdf.text_box( @labels[:due_date], size: 11, at: [x(284), y(position) - @push_down], width: x(240) ) @pdf.text_box( @document.due_date, size: 13, at: [x(384), y(position) - @push_down], width: x(146), align: :right ) if used? @labels[:sublabels][:due_date] position = issue_date_present ? 463 : 478 position -= 10 if used? @labels[:sublabels][:issue_date] @pdf.text_box( @labels[:sublabels][:due_date], size: 10, at: [x(284), y(position) - @push_down], width: x(240) ) end end variable_symbol_present = !@document.variable_symbol.empty? if variable_symbol_present position = (issue_date_present || due_date_present) ? 483 : 498 position = issue_date_present && due_date_present ? 458 : 463 position -= 10 if used? @labels[:sublabels][:issue_date] position -= 10 if used? @labels[:sublabels][:due_date] @pdf.text_box( @labels[:variable_symbol], size: 11, at: [x(284), y(position) - @push_down], width: x(240) ) @pdf.text_box( @document.variable_symbol, size: 13, at: [x(384), y(position) - @push_down], width: x(146), align: :right ) if used? @labels[:sublabels][:variable_symbol] position = issue_date_present ? 443 : 458 position -= 10 if used? @labels[:sublabels][:issue_date] position -= 10 if used? @labels[:sublabels][:due_date] @pdf.text_box( @labels[:sublabels][:variable_symbol], size: 10, at: [x(284), y(position) - @push_down], width: x(240) ) end end if issue_date_present || due_date_present || variable_symbol_present big_box = (issue_date_present && due_date_present && variable_symbol_present && info_box_sublabels_used?) height = (issue_date_present && due_date_present) ? 75 : 60 height = big_box ? 110 : height height = @payment_box_height if @payment_box_height > height @pdf.stroke_rounded_rectangle([x(274), y(508) - @push_down], x(266), height, 6) @push_items_table += 23 if @push_items_table <= 18 @push_items_table += 24 if big_box #@push_items_table += 30 end end def big_info_box? !@document.issue_date.empty? && !@document.due_date.empty? && !@document.variable_symbol.empty? && info_box_sublabels_used? end def info_box_sublabels_used? used?(@labels[:sublabels][:issue_date]) || used?(@labels[:sublabels][:due_date]) || used?(@labels[:sublabels][:variable_symbol]) end # Build the following table for document items: # # ================================================================= # |Item | Quantity| Unit| Price per item| Tax| Total per item| # |-----|----------|------|----------------|-----|----------------| # |x | 2| hr| $2| $1| $4| # ================================================================= # # variable (2nd position), tax2 and tax3 (after tax) fields can be added # as well if necessary. variable does not come with any default label. # If a specific column miss data, it's omittted. # # Using sublabels one can change the table to look as: # # ================================================================= # |Item | Quantity| Unit| Price per item| Tax| Total per item| # |it. | nom.| un.| ppi.| t.| tpi.| # |-----|----------|------|----------------|-----|----------------| # |x | 2| hr| $2| $1| $4| # ================================================================= def build_items @pdf.move_down(23 + @push_items_table + @push_down) items_params = determine_items_structure items = build_items_data(items_params) headers = build_items_header(items_params) data = items.prepend(headers) options = { header: true, row_colors: [nil, 'ededed'], width: x(540, 2), cell_style: { borders: [] } } unless items.empty? @pdf.font_size(10) do @pdf.table(data, options) do row(0).background_color = 'e3e3e3' row(0).border_color = 'aaaaaa' row(0).borders = [:bottom] row(items.size - 1).borders = [:bottom] row(items.size - 1).border_color = 'd9d9d9' end end end end # Determine sections of the items table to show based on provided data def determine_items_structure items_params = {} @document.items.each do |item| items_params[:names] = true unless item.name.empty? items_params[:variables] = true unless item.variable.empty? items_params[:quantities] = true unless item.quantity.empty? items_params[:units] = true unless item.unit.empty? items_params[:prices] = true unless item.price.empty? items_params[:taxes] = true unless item.tax.empty? items_params[:taxes2] = true unless item.tax2.empty? items_params[:taxes3] = true unless item.tax3.empty? items_params[:amounts] = true unless item.amount.empty? end items_params end # Include only items params with provided data def build_items_data(items_params) @document.items.map do |item| line = [] line << { content: item.name, borders: [:bottom], align: :left } if items_params[:names] line << { content: item.variable, align: :right } if items_params[:variables] line << { content: item.quantity, align: :right } if items_params[:quantities] line << { content: item.unit, align: :right } if items_params[:units] line << { content: item.price, align: :right } if items_params[:prices] line << { content: item.tax, align: :right } if items_params[:taxes] line << { content: item.tax2, align: :right } if items_params[:taxes2] line << { content: item.tax3, align: :right } if items_params[:taxes3] line << { content: item.amount, align: :right } if items_params[:amounts] line end end # Include only relevant headers def build_items_header(items_params) headers = [] headers << { content: label_with_sublabel(:item), align: :left } if items_params[:names] headers << { content: label_with_sublabel(:variable), align: :right } if items_params[:variables] headers << { content: label_with_sublabel(:quantity), align: :right } if items_params[:quantities] headers << { content: label_with_sublabel(:unit), align: :right } if items_params[:units] headers << { content: label_with_sublabel(:price_per_item), align: :right } if items_params[:prices] headers << { content: label_with_sublabel(:tax), align: :right } if items_params[:taxes] headers << { content: label_with_sublabel(:tax2), align: :right } if items_params[:taxes2] headers << { content: label_with_sublabel(:tax3), align: :right } if items_params[:taxes3] headers << { content: label_with_sublabel(:amount), align: :right } if items_params[:amounts] headers end # This merge a label with its sublabel on a new line def label_with_sublabel(symbol) value = @labels[symbol] if used? @labels[:sublabels][symbol] value += "\n#{@labels[:sublabels][symbol]}" end value end # Build the following summary: # # Subtotal: 175 # Tax: 5 # Tax 2: 10 # Tax 3: 20 # # Total: $ 200 # # The first part is implemented as a table without borders. def build_total @pdf.move_down(25) items = [] items << [{ content: "#{@labels[:subtotal]}:#{build_sublabel_for_total_table(:subtotal)}", align: :right }, @document.subtotal] \ unless @document.subtotal.empty? items << [{ content: "#{@labels[:tax]}:#{build_sublabel_for_total_table(:tax)}", align: :right }, @document.tax] \ unless @document.tax.empty? items << [{ content: "#{@labels[:tax2]}:#{build_sublabel_for_total_table(:tax2)}", align: :right }, @document.tax2] \ unless @document.tax2.empty? items << [{ content: "#{@labels[:tax3]}:#{build_sublabel_for_total_table(:tax3)}", align: :right }, @document.tax3] \ unless @document.tax3.empty? width = [ "#{@labels[:subtotal]}#{@document.subtotal}".size, "#{@labels[:tax]}#{@document.tax}".size, "#{@labels[:tax2]}#{@document.tax2}".size, "#{@labels[:tax3]}#{@document.tax3}".size ].max * 8 options = { cell_style: { borders: [] } } @pdf.span(x(width), position: :right) do @pdf.table(items, options) unless items.empty? end @pdf.move_down(15) unless @document.total.empty? @pdf.text( "#{@labels[:total]}: #{@document.total}", size: 16, align: :right, style: :bold ) @pdf.move_down(5) if used? @labels[:sublabels][:total] @pdf.text( "#{@labels[:sublabels][:total]}: #{@document.total}", size: 12, align: :right ) end end end # Return sublabel on a new line or empty string def build_sublabel_for_total_table(sublabel) if used? @labels[:sublabels][sublabel] "\n#{@labels[:sublabels][sublabel]}:" else '' end end # Insert a logotype at the left bottom of the document # # If a note is present, position it on top of it. def build_logo if @logo && !@logo.empty? bottom = @document.note.empty? ? 75 : (75 + note_height) @pdf.image(@logo, at: [0, bottom], fit: [200, 50]) end end # Insert a stamp (with signature) after the total table def build_stamp if @stamp && !@stamp.empty? @pdf.move_down(15) @pdf.image(@stamp, position: :right) end end # Note at the end def build_note @pdf.text_box( "#{@document.note}", size: 10, at: [0, note_height], width: x(450), align: :left ) end def note_height @note_height ||= begin num_of_lines = @document.note.lines.count (num_of_lines * 11) end end # Include page numbers if we got more than one page def build_footer @pdf.number_pages( '<page> / <total>', start_count_at: 1, at: [@pdf.bounds.right - 50, 0], align: :right, size: 12 ) unless @pdf.page_count == 1 end def used?(element) element && !element.empty? end def letter? @page_size.name == 'LETTER' end # Return correct x/width relative to page size def x(value, adjust = 1) return value if letter? width_ratio = value / PAGE_SIZES[:letter].width (width_ratio * @page_size.width) - adjust end # Return correct y/height relative to page size def y(value) return value if letter? width_ratio = value / PAGE_SIZES[:letter].height width_ratio * @page_size.height end def merge_custom_labels(labels = {}) custom_labels = if labels hash_keys_to_symbols(labels) else {} end PDFDocument.labels.merge(custom_labels) end def hash_keys_to_symbols(value) return value unless value.is_a? Hash value.inject({}) do |memo, (k, v)| memo[k.to_sym] = hash_keys_to_symbols(v) memo end end end end