├── .gitignore ├── .travis.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── to_spreadsheet.rb └── to_spreadsheet │ ├── context.rb │ ├── context │ └── pairing.rb │ ├── rails │ ├── action_pack_renderers.rb │ ├── mime_types.rb │ └── view_helpers.rb │ ├── railtie.rb │ ├── renderer.rb │ ├── rule.rb │ ├── rule │ ├── base.rb │ ├── container.rb │ ├── default_value.rb │ ├── format.rb │ ├── sheet.rb │ └── workbook.rb │ ├── selectors.rb │ ├── themes │ └── default.rb │ ├── type_from_value.rb │ └── version.rb ├── spec ├── defaults_spec.rb ├── format_spec.rb ├── gemfiles │ ├── Gemfile.rails-3.2 │ ├── Gemfile.rails-4.0 │ ├── Gemfile.rails-4.1 │ └── Gemfile.rails-4.2 ├── internal │ └── log │ │ └── .gitignore ├── rails_integration_spec.rb ├── spec_helper.rb ├── support │ └── table.html.haml ├── types_spec.rb └── worksheets_spec.rb └── to_spreadsheet.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .bundle/ 3 | log/*.log 4 | pkg/ 5 | *.gem 6 | Gemfile.lock 7 | spec/gemfiles/*.lock 8 | .ruby-version 9 | .ruby-gemset 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | cache: bundler 3 | bundler_args: --path ../../vendor/bundle 4 | rvm: 5 | - 2.3.1 6 | - jruby-9000 7 | gemfile: 8 | - spec/gemfiles/Gemfile.rails-3.2 9 | - spec/gemfiles/Gemfile.rails-4.0 10 | - spec/gemfiles/Gemfile.rails-4.1 11 | - spec/gemfiles/Gemfile.rails-4.2 12 | - Gemfile 13 | script: bundle exec rspec 14 | 15 | matrix: 16 | exclude: 17 | - rvm: jruby-9000 18 | gemfile: Gemfile 19 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec 4 | 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | LICENSE 2 | 3 | The MIT License 4 | 5 | Copyright 2011 Gleb Mazovetskiy 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining 8 | a copy of this software and associated documentation files (the 9 | "Software"), to deal in the Software without restriction, including 10 | without limitation the rights to use, copy, modify, merge, publish, 11 | distribute, sublicense, and/or sell copies of the Software, and to 12 | permit persons to whom the Software is furnished to do so, subject to 13 | the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 20 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 22 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 24 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # to_spreadsheet [![Build Status](https://secure.travis-ci.org/glebm/to_spreadsheet.png?branch=master)](http://travis-ci.org/glebm/to_spreadsheet) 2 | 3 | to_spreadsheet lets your Rails 3+ app render Excel files using the existing slim/haml/erb/etc views. 4 | 5 | Installation 6 | ------------ 7 | 8 | Add it to your Gemfile: 9 | ```ruby 10 | gem 'to_spreadsheet' 11 | ``` 12 | 13 | Usage 14 | ----- 15 | 16 | In the controller: 17 | ```ruby 18 | # my_thingies_controller.rb 19 | class MyThingiesController < ApplicationController 20 | respond_to :xlsx, :html 21 | def index 22 | @my_items = MyItem.all 23 | respond_to do |format| 24 | format.html 25 | format.xlsx { render xlsx: :index, filename: "my_items_doc" } 26 | end 27 | end 28 | end 29 | ``` 30 | 31 | In the view partial: 32 | ```haml 33 | # _my_items.haml 34 | %table 35 | %caption My items 36 | %thead 37 | %tr 38 | %td ID 39 | %td Name 40 | %tbody 41 | - my_items.each do |my_item| 42 | %tr 43 | %td.number= my_item.id 44 | %td= my_item.name 45 | %tfoot 46 | %tr 47 | %td(colspan="2") #{my_items.length} 48 | ``` 49 | 50 | In the XLSX view: 51 | ```haml 52 | # index.xlsx.haml 53 | = render 'my_items', my_items: @my_items 54 | ``` 55 | 56 | In the HTML view: 57 | ```haml 58 | # index.html.haml 59 | = link_to 'Download spreadsheet', my_items_url(format: :xlsx) 60 | = render 'my_items', my_items: @my_items 61 | ``` 62 | 63 | ### Worksheets 64 | 65 | Every table in the view will be converted to a separate sheet. 66 | The sheet title will be assigned to the value of the table’s caption element if it exists. 67 | 68 | ### Formatting 69 | 70 | You can define formats in your view file (local to the view) or in the initializer 71 | 72 | ```ruby 73 | format_xls 'table.my-table' do 74 | workbook use_autowidth: true 75 | sheet orientation: landscape 76 | format 'th', b: true # bold 77 | format 'tbody tr', bg_color: lambda { |row| 'ddffdd' if row.row_index.odd? } 78 | format 'A3:B10', i: true # italic 79 | format column: 0, width: 35 80 | format 'td.custom', lambda { |cell| modify cell somehow.} 81 | # default value (fallback value when value is blank or 0 for integer / float) 82 | default 'td.price', 10 83 | end 84 | ``` 85 | 86 | For the full list of supported properties head here: http://rubydoc.info/github/randym/axlsx/Axlsx/Styles#add_style-instance_method 87 | In addition, for column formats, Axlsx columnInfo properties are also supported 88 | 89 | ### Advanced formatting 90 | 91 | to_spreadsheet [associates](https://github.com/glebm/to_spreadsheet/blob/master/lib/to_spreadsheet/renderer.rb#L33) HTML nodes with Axlsx objects as follows: 92 | 93 | | HTML tag | Axlsx object | 94 | |----------|--------------| 95 | | table | worksheet | 96 | | tr | row | 97 | | td, th | cell | 98 | 99 | For example, to directly manipulate a worksheet: 100 | ```ruby 101 | format_xls do 102 | format 'table' do |worksheet| 103 | worksheet.add_chart ... 104 | # to get the associated Nokogiri node: 105 | el = context.to_xml_node(worksheet) 106 | end 107 | end 108 | ``` 109 | 110 | ### Themes 111 | 112 | You can define themes, i.e. blocks of formatting code: 113 | ```ruby 114 | ToSpreadsheet.theme :zebra do 115 | format 'tr', bg_color: lambda { |row| 'ddffdd' if row.row_index.odd? } 116 | end 117 | ``` 118 | 119 | And then use them: 120 | ```ruby 121 | format_xls 'table.zebra', ToSpreadsheet.theme(:zebra) 122 | ``` 123 | 124 | ### Using along side axlsx-rails 125 | If you are using [axlsx-rails](https://github.com/straydogstudio/axlsx_rails), :xlsx renderer might have already been defined. In that case define a custome renderer using 126 | ```ruby 127 | # app/config/application.rb 128 | config.to_spreadsheet.renderer = :html2xlsx 129 | ``` 130 | 131 | And then in controller 132 | ```ruby 133 | respond_to do |format| 134 | format.html2xlsx 135 | end 136 | ``` 137 | 138 | ### Types 139 | 140 | The default theme uses class names on td/th to cast values. 141 | Here is the list of class to type mapping: 142 | 143 | | CSS class | Format | 144 | |------------------|--------------------------| 145 | | decimal or float | Decimal | 146 | | num or int | Integer | 147 | | datetime | DateTime (Chronic.parse) | 148 | | date | Date (Date.parse) | 149 | | time | Time (Chronic.parse) | 150 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require 'rubygems' 3 | require 'bundler/gem_tasks' 4 | 5 | require 'rake' 6 | require 'rdoc/task' 7 | require 'rspec/core/rake_task' 8 | RSpec::Core::RakeTask.new(:spec) 9 | 10 | task :env do 11 | $: << File.expand_path('lib', File.dirname(__FILE__)) 12 | require 'to_spreadsheet' 13 | include ToSpreadsheet::Helpers 14 | end 15 | 16 | desc 'Generate a simple xlsx file' 17 | task :write_test_xlsx => :env do 18 | require 'haml' 19 | path = '/tmp/spreadsheet.xlsx' 20 | html = Haml::Engine.new(File.read('spec/support/table.html.haml')).render 21 | ToSpreadsheet::Renderer.to_package(html).serialize(path) 22 | puts "Written to #{path}" 23 | end 24 | 25 | desc "Test all rails versions" 26 | task :test_all_rails do 27 | Dir.glob("./spec/gemfiles/*{[!.lock]}").each do |gemfile| 28 | puts "TESTING WITH #{gemfile}" 29 | system "BUNDLE_GEMFILE=#{gemfile} bundle | grep Installing" 30 | system "BUNDLE_GEMFILE=#{gemfile} bundle exec rspec" 31 | end 32 | end -------------------------------------------------------------------------------- /lib/to_spreadsheet.rb: -------------------------------------------------------------------------------- 1 | require 'to_spreadsheet/version' 2 | require 'to_spreadsheet/context' 3 | require 'to_spreadsheet/renderer' 4 | 5 | module ToSpreadsheet 6 | class << self 7 | def renderer 8 | @renderer ||= :xlsx 9 | end 10 | 11 | def theme(name, &formats) 12 | @themes ||= {} 13 | if formats 14 | @themes[name] = formats 15 | else 16 | @themes[name] 17 | end 18 | end 19 | end 20 | end 21 | 22 | require 'to_spreadsheet/railtie' if defined?(Rails) 23 | require 'to_spreadsheet/themes/default' 24 | ToSpreadsheet::Context.global.format_xls ToSpreadsheet.theme(:default) -------------------------------------------------------------------------------- /lib/to_spreadsheet/context.rb: -------------------------------------------------------------------------------- 1 | require 'to_spreadsheet/context/pairing' 2 | require 'to_spreadsheet/rule' 3 | require 'to_spreadsheet/rule/base' 4 | require 'to_spreadsheet/rule/container' 5 | require 'to_spreadsheet/rule/format' 6 | require 'to_spreadsheet/rule/default_value' 7 | require 'to_spreadsheet/rule/sheet' 8 | require 'to_spreadsheet/rule/workbook' 9 | 10 | module ToSpreadsheet 11 | # This is the DSL context 12 | class Context 13 | include Pairing 14 | attr_accessor :rules 15 | 16 | class << self 17 | def global 18 | @global ||= new 19 | end 20 | 21 | def current 22 | Thread.current[:_to_spreadsheet_ctx] 23 | end 24 | 25 | def current=(ctx) 26 | Thread.current[:_to_spreadsheet_ctx] = ctx 27 | end 28 | 29 | def with_context(ctx, &block) 30 | old = current 31 | self.current = ctx 32 | r = block.call(ctx) 33 | self.current = old 34 | r 35 | end 36 | end 37 | 38 | def initialize(wb_options = nil) 39 | @rules = [] 40 | workbook wb_options if wb_options 41 | end 42 | 43 | # Examples: 44 | # format_xls 'table.zebra' do 45 | # format 'td', lambda { |cell| {b: true} if cell.row.even? } 46 | # end 47 | # format_xls ToSpreadsheet.theme(:a_theme) 48 | # format_xls 'table.zebra', ToSpreadsheet.theme(:zebra) 49 | def format_xls(selector = nil, theme = nil, &block) 50 | selector, theme = nil, selector if selector.is_a?(Proc) && !theme 51 | process_dsl(selector, &theme) if theme 52 | process_dsl(selector, &block) if block 53 | self 54 | end 55 | 56 | def process_dsl(selector, &block) 57 | @rule_container = add_rule :container, *selector_query(selector) 58 | instance_eval(&block) 59 | @rule_container = nil 60 | end 61 | 62 | def workbook(selector = nil, value) 63 | add_rule :workbook, *selector_query(selector), value 64 | end 65 | 66 | # format 'td.b', b: true # bold 67 | # format column: 0, width: 50 68 | # format 'A1:C30', b: true 69 | # Accepted properties: http://rubydoc.info/github/randym/axlsx/Axlsx/Cell 70 | # column format also accepts Axlsx columnInfo settings 71 | def format(selector = nil, options, &block) 72 | if !selector && options.is_a?(String) 73 | selector = options 74 | options = nil 75 | else 76 | options = options.dup 77 | end 78 | options ||= block 79 | selector = selector_query(selector, options) 80 | add_rule :format, *selector, options 81 | end 82 | 83 | # sheet 'table.landscape', page_setup: { orientation: landscape } 84 | def sheet(selector = nil, options) 85 | options = options.dup 86 | selector = selector_query(selector, options) 87 | add_rule :sheet, *selector, options 88 | end 89 | 90 | # default 'td.c', 5 91 | def default(selector, value) 92 | selector = selector_query(selector) 93 | add_rule :default_value, *selector, value 94 | end 95 | 96 | def add_rule(rule_type, selector_type, selector_value, options = {}) 97 | rule = ToSpreadsheet::Rule.make(rule_type, selector_type, selector_value, options) 98 | if @rule_container 99 | @rule_container.children << rule 100 | else 101 | @rules << rule 102 | end 103 | rule 104 | end 105 | 106 | # A new context 107 | def merge(other_context) 108 | ctx = Context.new() 109 | ctx.rules = rules + other_context.rules 110 | ctx 111 | end 112 | 113 | private 114 | 115 | # Extract selector query from DSL arguments 116 | # 117 | # Figures out text type: 118 | # selector_query('td.num') # [:css, "td.num"] 119 | # selector_query('A0:B5') # [:range, "A0:B5"] 120 | # 121 | # If text is nil, extracts first of row, range, and css keys 122 | # selector_query(nil, {column: 0}] # [:column, 0] 123 | def selector_query(text, opts = {}) 124 | if text 125 | if text =~ /:/ && text[0].upcase == text[0] 126 | return [:range, text] 127 | else 128 | return [:css, text] 129 | end 130 | end 131 | key = [:column, :row, :range].detect { |key| opts.key?(key) } 132 | return [key, opts.delete(key)] if key 133 | [nil, nil] 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/to_spreadsheet/context/pairing.rb: -------------------------------------------------------------------------------- 1 | module ToSpreadsheet 2 | class Context 3 | # Axlsx classes <-> Nokogiri table nodes round-tripping 4 | module Pairing 5 | def assoc!(entity, node) 6 | @entity_to_node ||= {} 7 | @node_to_entity ||= {} 8 | @entity_to_node[entity] = node 9 | @node_to_entity[node] = entity 10 | end 11 | 12 | def to_xls_entity(node) 13 | @node_to_entity[node] 14 | end 15 | 16 | def to_xml_node(entity) 17 | @entity_to_node[entity] 18 | end 19 | 20 | def xml_node_and_xls_entity(entity) 21 | [@entity_to_node[entity], entity, @node_to_entity[entity]].compact 22 | end 23 | 24 | def clear_assoc! 25 | @entity_to_node = {} 26 | @node_to_entity = {} 27 | end 28 | end 29 | end 30 | end -------------------------------------------------------------------------------- /lib/to_spreadsheet/rails/action_pack_renderers.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require 'action_controller/metal/renderers' 3 | 4 | # in rails 3.2 it's ActiveSupport::VERSION 5 | # in rails 4.0+ it's ActiveSupport.version (instance of Gem::Version) 6 | if ActiveSupport.respond_to?(:version) && ActiveSupport.version.to_s >= "4.2.0" 7 | # For rails 4.2 8 | require 'action_controller/responder' 9 | else 10 | # For rails 3.2 - rails 4.1 11 | require 'action_controller/metal/responder' 12 | end 13 | 14 | 15 | # This will let us do thing like `render :xlsx => 'index'` 16 | # This is similar to how Rails internally implements its :json and :xml renderers 17 | ActionController::Renderers.add ToSpreadsheet.renderer do |template, options| 18 | filename = options[:filename] || options[:template] || 'data' 19 | data = ToSpreadsheet::Context.with_context ToSpreadsheet::Context.global.merge(ToSpreadsheet::Context.new) do |context| 20 | html = render_to_string(template, options.merge(template: template.to_s, formats: [:xlsx, :html])) 21 | ToSpreadsheet::Renderer.to_data(html, context) 22 | end 23 | send_data data, type: ToSpreadsheet.renderer, disposition: %(attachment; filename="#{filename}.xlsx") 24 | end 25 | 26 | class ActionController::Responder 27 | # This sets up a default render call for when you do 28 | # respond_to do |format| 29 | # format.xlsx 30 | # end 31 | define_method "to_#{ToSpreadsheet.renderer}" do 32 | controller.render ToSpreadsheet.renderer => controller.action_name 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/to_spreadsheet/rails/mime_types.rb: -------------------------------------------------------------------------------- 1 | require 'action_dispatch/http/mime_type' 2 | Mime::Type.register "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ToSpreadsheet.renderer unless Mime::Type.lookup_by_extension(ToSpreadsheet.renderer) -------------------------------------------------------------------------------- /lib/to_spreadsheet/rails/view_helpers.rb: -------------------------------------------------------------------------------- 1 | module ToSpreadsheet 2 | module Rails 3 | module ViewHelpers 4 | def format_xls(selector = nil, &block) 5 | ctx = ToSpreadsheet::Context.current 6 | return unless ctx 7 | ctx.format_xls selector, &block 8 | end 9 | end 10 | end 11 | end -------------------------------------------------------------------------------- /lib/to_spreadsheet/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'rails/railtie' 2 | module ToSpreadsheet 3 | class Railtie < ::Rails::Railtie 4 | config.to_spreadsheet = ActiveSupport::OrderedOptions.new 5 | config.to_spreadsheet.renderer = ToSpreadsheet.renderer 6 | 7 | config.after_initialize do |app| 8 | ToSpreadsheet.instance_variable_set("@renderer", app.config.to_spreadsheet.renderer) 9 | 10 | require 'to_spreadsheet/rails/action_pack_renderers' 11 | require 'to_spreadsheet/rails/view_helpers' 12 | require 'to_spreadsheet/rails/mime_types' 13 | ActionView::Base.send :include, ToSpreadsheet::Rails::ViewHelpers 14 | end 15 | end 16 | end -------------------------------------------------------------------------------- /lib/to_spreadsheet/renderer.rb: -------------------------------------------------------------------------------- 1 | require 'axlsx' 2 | require 'nokogiri' 3 | 4 | module ToSpreadsheet 5 | module Renderer 6 | extend self 7 | 8 | def to_stream(html, context = nil) 9 | to_package(html, context).to_stream 10 | end 11 | 12 | def to_data(html, context = nil) 13 | to_package(html, context).to_stream.read 14 | end 15 | 16 | def to_package(html, context = nil) 17 | context ||= ToSpreadsheet::Context.global.merge(Context.new) 18 | package = build_package(html, context) 19 | context.rules.each do |rule| 20 | #Rails.logger.debug "Applying #{rule}" 21 | rule.apply(context, package) 22 | end 23 | package 24 | end 25 | 26 | private 27 | 28 | def build_package(html, context) 29 | package = ::Axlsx::Package.new 30 | spreadsheet = package.workbook 31 | doc = Nokogiri::HTML::Document.parse(html) 32 | # Workbook <-> %document association 33 | context.assoc! spreadsheet, doc 34 | doc.css('table').each_with_index do |xml_table, i| 35 | sheet = spreadsheet.add_worksheet( 36 | name: xml_table.css('caption').inner_text.presence || xml_table['name'] || "Sheet #{i + 1}" 37 | ) 38 | # Sheet <-> %table association 39 | context.assoc! sheet, xml_table 40 | xml_table.css('tr').each do |row_node| 41 | xls_row = sheet.add_row 42 | # Row <-> %tr association 43 | context.assoc! xls_row, row_node 44 | row_node.css('th,td').each do |cell_node| 45 | xls_col = xls_row.add_cell cell_node.inner_text 46 | # Cell <-> th or td association 47 | context.assoc! xls_col, cell_node 48 | end 49 | end 50 | end 51 | package 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/to_spreadsheet/rule.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/string' 2 | module ToSpreadsheet 3 | module Rule 4 | def self.make(rule_type, selector_type, selector_value, options) 5 | klass = "ToSpreadsheet::Rule::#{rule_type.to_s.camelize}".constantize 6 | klass.new(selector_type, selector_value, options) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/to_spreadsheet/rule/base.rb: -------------------------------------------------------------------------------- 1 | require 'to_spreadsheet/selectors' 2 | module ToSpreadsheet 3 | module Rule 4 | class Base 5 | include ::ToSpreadsheet::Selectors 6 | attr_reader :selector_type, :selector_query, :options 7 | 8 | def initialize(selector_type, selector_query, options) 9 | @selector_type = selector_type 10 | @selector_query = selector_query 11 | @options = options 12 | end 13 | 14 | def applies_to?(context, xml_or_xls_node) 15 | return true if !selector_type 16 | node, entity = context.xml_node_and_xls_entity(xml_or_xls_node) 17 | sheet = entity.is_a?(::Axlsx::Workbook) ? entity : (entity.respond_to?(:workbook) ? entity.workbook : entity.worksheet.workbook) 18 | doc = context.to_xml_node(sheet) 19 | query_match?( 20 | selector_type: selector_type, 21 | selector_query: selector_query, 22 | xml_document: doc, 23 | xml_node: node, 24 | xls_worksheet: sheet, 25 | xls_entity: entity 26 | ) 27 | end 28 | 29 | def type 30 | self.class.name.demodulize.underscore.to_sym 31 | end 32 | 33 | def to_s 34 | "Rule [#{type}, #{selector_type}, #{selector_query}, #{options}" 35 | end 36 | end 37 | end 38 | end -------------------------------------------------------------------------------- /lib/to_spreadsheet/rule/container.rb: -------------------------------------------------------------------------------- 1 | module ToSpreadsheet 2 | module Rule 3 | # Applies children rules to all the matching tables 4 | class Container < Base 5 | attr_reader :children 6 | def initialize(*args) 7 | super 8 | @children = [] 9 | end 10 | 11 | def apply(context, package) 12 | package.workbook.worksheets.each do |sheet| 13 | table = context.to_xml_node(sheet) 14 | if applies_to?(context, table) 15 | children.each { |c| c.apply(context, sheet) } 16 | end 17 | end 18 | end 19 | 20 | def to_s 21 | "Rules(#{selector_type}, #{selector_query}) [#{children.map(&:to_s)}]" 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/to_spreadsheet/rule/default_value.rb: -------------------------------------------------------------------------------- 1 | require 'to_spreadsheet/type_from_value' 2 | module ToSpreadsheet 3 | module Rule 4 | class DefaultValue < Base 5 | include ::ToSpreadsheet::TypeFromValue 6 | 7 | def apply(context, sheet) 8 | default = options 9 | each_cell context, sheet, selector_type, selector_query do |cell| 10 | unless cell.value.present? && 11 | !([:integer, :float].include?(cell.type) && cell.value.zero?) 12 | cell.type = cell_type_from_value(default) 13 | cell.value = default 14 | end 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/to_spreadsheet/rule/format.rb: -------------------------------------------------------------------------------- 1 | require 'to_spreadsheet/type_from_value' 2 | require 'set' 3 | module ToSpreadsheet 4 | module Rule 5 | class Format < Base 6 | include ::ToSpreadsheet::TypeFromValue 7 | def apply(context, sheet) 8 | wb = sheet.workbook 9 | case selector_type 10 | when :css 11 | css_match selector_query, context.to_xml_node(sheet) do |xml_node| 12 | add_and_apply_style wb, context, context.to_xls_entity(xml_node) 13 | end 14 | when :row 15 | sheet.row_style selector_query, options if options.present? 16 | when :column 17 | inline_styles = options.except(*COL_INFO_PROPS) 18 | sheet.col_style selector_query, inline_styles if inline_styles.present? 19 | apply_col_info sheet.column_info[selector_query] 20 | when :range 21 | add_and_apply_style wb, range_match(selector_query, sheet), context 22 | end 23 | end 24 | 25 | private 26 | COL_INFO_PROPS = %w(bestFit collapsed customWidth hidden phonetic width).map(&:to_sym).to_set 27 | def apply_col_info(col_info) 28 | return if col_info.nil? 29 | options.each do |k, v| 30 | if COL_INFO_PROPS.include?(k) 31 | col_info.send :"#{k}=", v 32 | end 33 | end 34 | end 35 | 36 | def add_and_apply_style(wb, context, xls_ent) 37 | # Custom format rule 38 | # format 'td.sel', lambda { |node| ...} 39 | if self.options.is_a?(Proc) 40 | context.instance_exec(xls_ent, &self.options) 41 | return 42 | end 43 | 44 | options = self.options.dup 45 | # Compute Proc rules 46 | # format 'td.sel', color: lambda {|node| ...} 47 | options.each do |k, v| 48 | options[k] = context.instance_exec(xls_ent, &v) if v.is_a?(Proc) 49 | end 50 | 51 | style = wb.styles.add_style options 52 | cells = xls_ent.respond_to?(:cells) ? xls_ent.cells : [xls_ent] 53 | cells.each { |cell| cell.style = style } 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/to_spreadsheet/rule/sheet.rb: -------------------------------------------------------------------------------- 1 | module ToSpreadsheet 2 | module Rule 3 | class Sheet < Base 4 | def apply(context, sheet) 5 | options.each { |k, v| 6 | if v.is_a?(Hash) 7 | sub = sheet.send(k) 8 | v.each do |sub_k, sub_v| 9 | sub.send :"#{sub_k}=", sub_v 10 | end 11 | else 12 | sheet.send :"#{k}=", v 13 | end 14 | } 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/to_spreadsheet/rule/workbook.rb: -------------------------------------------------------------------------------- 1 | module ToSpreadsheet 2 | module Rule 3 | class Workbook < Base 4 | def apply(context, sheet) 5 | workbook = sheet.workbook 6 | options.each { |k, v| workbook.send :"#{k}=", v } 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/to_spreadsheet/selectors.rb: -------------------------------------------------------------------------------- 1 | module ToSpreadsheet 2 | # This is the DSL context for `format_xls` 3 | # Query types: :css, :column, :row or :range 4 | # Query values: 5 | # For css: [String] css selector 6 | # For column and row: [Fixnum] column/row number 7 | # For range: [String] table range, e.g. A4:B5 8 | module Selectors 9 | # Flexible API query match 10 | # Options (all optional): 11 | # xls_worksheet 12 | # xls_entity 13 | # xml_document 14 | # xml_node 15 | # selector_type :css, :column, :row or :range 16 | # selector_query 17 | def query_match?(options) 18 | return true if !options[:selector_query] 19 | case options[:selector_type] 20 | when :css 21 | css_match? options[:selector_query], options[:xml_document], options[:xml_node] 22 | when :column 23 | return false unless [Axlsx::Row, Axlsx::Cell].include?(options[:xml_node].class) 24 | column_number_match? options[:selector_query], options[:xml_node] 25 | when :row 26 | return false unless Axlsx::Cell == options[:xml_node].class 27 | row_number_match? options[:selector_query], options[:xml_node] 28 | when :range 29 | return false if entity.is_a?(Axlsx::Cell) 30 | range_contains? options[:selector_query], options[:xml_node] 31 | else 32 | raise "Unsupported type #{options[:selector_type].inspect} (:css, :column, :row or :range expected)" 33 | end 34 | end 35 | 36 | def each_cell(context, sheet, selector_type, selector_query, &block) 37 | if !selector_type 38 | sheet.rows.each do |row| 39 | sheet.cells.each do |cell| 40 | block.(cell) 41 | end 42 | end 43 | return 44 | end 45 | case selector_type 46 | when :css 47 | css_match selector_query, context.to_xml_node(sheet) do |xml_node| 48 | block.(context.to_xls_entity(xml_node)) 49 | end 50 | when :column 51 | sheet.cols[selector_query].cells.each(&block) 52 | when :row 53 | sheet.cols[selector_query].cells.each(&block) 54 | when :range 55 | sheet[range].each(&block) 56 | end 57 | end 58 | 59 | def css_match(css_selector, xml_node, &block) 60 | result = xml_node.css(css_selector) 61 | block.call(xml_node) if xml_node.matches?(css_selector) 62 | result.each(&block) 63 | end 64 | 65 | def css_match?(css_selector, xml_document, xml_node) 66 | xml_document.css(css_selector).include?(xml_node) 67 | end 68 | 69 | def row_number_match?(row_number, xls_row_or_cell) 70 | if xls_row_or_cell.is_a? Axlsx::Row 71 | row_number == xls_row_or_cell.index 72 | elsif xls_row_or_cell.is_a? Axlsx::Cell 73 | row_number == xls_row_or_cell.row.index 74 | end 75 | end 76 | 77 | def column_number_match?(column_number, xls_cell) 78 | xls_cell.index == column_number if xls_cell.is_a?(Axlsx::Cell) 79 | end 80 | 81 | def range_match(range, xls_sheet) 82 | xls_sheet[range] 83 | end 84 | 85 | def range_contains?(range, xls_cell) 86 | pos = xls_cell.pos 87 | top_left, bot_right = range.split(':').map { |s| Axlsx.name_to_indices(s) } 88 | pos[0] >= top_left[0] && pos[0] <= bot_right[0] && pos[1] >= top_left[1] && pos[1] <= bot_right[1] 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/to_spreadsheet/themes/default.rb: -------------------------------------------------------------------------------- 1 | require 'chronic' 2 | 3 | module ToSpreadsheet::Themes 4 | module Default 5 | ::ToSpreadsheet.theme :default do 6 | workbook use_autowidth: true, 7 | use_shared_strings: true 8 | sheet page_setup: { 9 | fit_to_height: 1, 10 | fit_to_width: 1, 11 | orientation: :landscape 12 | } 13 | # Set value type based on CSS class 14 | format 'td,th', lambda { |cell| 15 | val = cell.value 16 | case to_xml_node(cell)[:class] 17 | when /decimal|float/ 18 | cell.type = :float 19 | when /num|int/ 20 | cell.type = :integer 21 | when /bool/ 22 | cell.type = :boolean 23 | # Parse (date)times and dates with Chronic and Date.parse 24 | when /datetime|time/ 25 | val = Chronic.parse(val) 26 | if val 27 | cell.type = :time 28 | cell.value = val 29 | end 30 | when /date/ 31 | val = (Date.parse(val) rescue val) 32 | if val.present? 33 | cell.type = :date 34 | cell.value = val 35 | end 36 | end 37 | } 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/to_spreadsheet/type_from_value.rb: -------------------------------------------------------------------------------- 1 | module ToSpreadsheet 2 | module TypeFromValue 3 | def cell_type_from_value(v) 4 | if v.is_a?(Date) 5 | :date 6 | elsif v.is_a?(Time) 7 | :time 8 | elsif v.is_a?(TrueClass) || v.is_a?(FalseClass) 9 | :boolean 10 | elsif v.to_s.match(/\A[+-]?\d+?\Z/) #numeric 11 | :integer 12 | elsif v.to_s.match(/\A[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?\Z/) #float 13 | :float 14 | else 15 | :string 16 | end 17 | end 18 | end 19 | end -------------------------------------------------------------------------------- /lib/to_spreadsheet/version.rb: -------------------------------------------------------------------------------- 1 | module ToSpreadsheet 2 | VERSION = '1.1.0' 3 | end 4 | -------------------------------------------------------------------------------- /spec/defaults_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | 4 | 5 | describe ToSpreadsheet::Rule::DefaultValue do 6 | let :spreadsheet do 7 | build_spreadsheet(haml: < 3.2.22' 6 | -------------------------------------------------------------------------------- /spec/gemfiles/Gemfile.rails-4.0: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec path: "../.." 4 | 5 | gem 'rails', '~> 4.0.13' 6 | -------------------------------------------------------------------------------- /spec/gemfiles/Gemfile.rails-4.1: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec path: "../.." 4 | 5 | gem 'rails', '~> 4.1.13' 6 | -------------------------------------------------------------------------------- /spec/gemfiles/Gemfile.rails-4.2: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec path: "../.." 4 | 5 | gem 'rails', '~> 4.2.4' 6 | -------------------------------------------------------------------------------- /spec/internal/log/.gitignore: -------------------------------------------------------------------------------- 1 | *.log -------------------------------------------------------------------------------- /spec/rails_integration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'to_spreadsheet/railtie' 2 | 3 | describe ToSpreadsheet::Railtie do 4 | 5 | it "registers a renderer" do 6 | expect(ToSpreadsheet.renderer).to eq(:html2xlsx) 7 | expect(ActionController::Renderers::RENDERERS).to include(ToSpreadsheet.renderer) 8 | end 9 | 10 | end 11 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAKE_ENV"] ||= 'test' 2 | $: << File.expand_path('../lib', __FILE__) 3 | require 'combustion' 4 | require 'to_spreadsheet' 5 | Combustion.initialize! :action_view, :action_controller do 6 | config.to_spreadsheet.renderer = :html2xlsx 7 | end 8 | require 'haml' 9 | 10 | module TestRendering 11 | def build_spreadsheet(src = {}) 12 | haml = if src[:haml] 13 | src[:haml] 14 | elsif src[:file] 15 | File.read(File.expand_path "support/#{src[:file]}", File.dirname(__FILE__)) 16 | end 17 | ToSpreadsheet::Context.with_context ToSpreadsheet::Context.global.merge(ToSpreadsheet::Context.new) do |context| 18 | html = Haml::Engine.new(haml).render(self) 19 | ToSpreadsheet::Renderer.to_package(html, context) 20 | end 21 | end 22 | end 23 | 24 | 25 | require 'to_spreadsheet/rails/view_helpers' 26 | RSpec.configure do |config| 27 | include TestRendering 28 | include ::ToSpreadsheet::Rails::ViewHelpers 29 | end 30 | -------------------------------------------------------------------------------- /spec/support/table.html.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | format_xls do 3 | format 'tr', color: lambda { |row| 'ddffdd' if row.index.even? } 4 | format 'th', b: true 5 | default 'td.num', 100 6 | end 7 | format_xls 'table#two' do 8 | format 'td', color: Axlsx::Color.new(rgb: 'ffaaaa') 9 | end 10 | 11 | %table 12 | %caption A worksheet 13 | %thead 14 | %tr 15 | %th Name 16 | %th Age 17 | %th Date 18 | %tbody 19 | %tr 20 | %td Gleb 21 | %td.num 20 22 | %td.date 27/05/1991 23 | %tr 24 | %td John 25 | %td.num 26 | %td.date 27 | 28 | %table#two 29 | %caption Another worksheet 30 | %thead 31 | %tr 32 | %th Name 33 | %th Age 34 | %th Date 35 | %tbody 36 | %tr 37 | %td Alice 38 | %td.float 19.5 39 | %td.date 10/05/1991 -------------------------------------------------------------------------------- /spec/types_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ToSpreadsheet::Themes::Default do 4 | let :spreadsheet do 5 | build_spreadsheet haml: <<-HAML 6 | 7 | %table 8 | %tr 9 | %td.num 20 10 | %td.float 1 11 | %td.date 27/05/1991 12 | %td.date 13 | HAML 14 | end 15 | 16 | let :row do 17 | spreadsheet.workbook.worksheets[0].rows[0] 18 | end 19 | 20 | context 'data types' do 21 | it 'num' do 22 | expect(row.cells[0].value).to eq(20) 23 | end 24 | 25 | it 'float' do 26 | expect(row.cells[1].type).to be :float 27 | end 28 | 29 | it 'date' do 30 | expect(row.cells[2].type).to be :date 31 | end 32 | 33 | it 'empty date' do 34 | expect(row.cells[3].type).not_to be :date 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/worksheets_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ToSpreadsheet::Renderer do 4 | let :spreadsheet do 5 | build_spreadsheet haml: <<-HAML 6 | %table 7 | %table 8 | HAML 9 | end 10 | 11 | context 'worksheets' do 12 | it 'are created 1 per ' do 13 | expect(spreadsheet.workbook.worksheets.length).to eq(2) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /to_spreadsheet.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH << File.join(File.dirname(__FILE__), 'lib') 2 | require 'to_spreadsheet/version' 3 | 4 | include_files = ["README*", "LICENSE", "Rakefile", "init.rb", 5 | "{lib,tasks,test,rails,generators,shoulda_macros}/**/*"].map do |glob| 6 | Dir[glob] 7 | end.flatten 8 | 9 | exclude_files = ["**/*.rbc", "test/s3.yml", "test/debug.log", "test/to_spreadsheet.db", "test/doc", "test/doc/*", 10 | "test/pkg", "test/pkg/*", "test/tmp", "test/tmp/*"].map do |glob| 11 | Dir[glob] 12 | end.flatten 13 | 14 | 15 | Gem::Specification.new do |s| 16 | s.name = "to_spreadsheet" 17 | s.email = "glex.spb@gmail.com" 18 | s.author = "Gleb Mazovetskiy" 19 | s.homepage = "https://github.com/glebm/to_spreadsheet" 20 | s.summary = "Render existing views as Excel documents with style!" 21 | s.description = "Render XLSX from Rails using existing views ( .*.html => .xlsx )" 22 | s.files = Dir["lib/**/*"] + ["MIT-LICENSE", "Rakefile", "README.rdoc"] 23 | s.version = ToSpreadsheet::VERSION 24 | s.platform = Gem::Platform::RUBY 25 | s.files = include_files - exclude_files 26 | s.require_path = "lib" 27 | s.test_files = Dir["spec/**/*_spec.rb"] 28 | s.extra_rdoc_files = Dir["README*"] 29 | s.add_dependency 'rails' 30 | s.add_dependency 'nokogiri' 31 | s.add_dependency 'caxlsx' 32 | s.add_dependency 'chronic' 33 | s.add_dependency 'responders' 34 | s.add_development_dependency 'haml-rails' 35 | s.add_development_dependency 'rspec-rails' 36 | s.add_development_dependency 'combustion' 37 | end 38 | --------------------------------------------------------------------------------