From e543a849d446770c59467a12fc1d6ab3c563db80 Mon Sep 17 00:00:00 2001 From: Oleksii Kriuchykhin Date: Wed, 5 Jun 2019 12:52:43 +0200 Subject: [PATCH] Add document file preview generation with LibreOffice [SCI-3145] --- Dockerfile | 4 ++ Dockerfile.production | 4 ++ Gemfile | 4 +- Gemfile.lock | 24 ++++++---- app/models/asset.rb | 44 +++++++++++------- app/views/shared/_file_preview_icon.html.erb | 6 ++- app/views/steps/attachments/_item.html.erb | 2 +- .../steps/attachments/_placeholder.html.erb | 2 +- config/initializers/constants.rb | 2 + config/initializers/paperclip.rb | 21 +++++++++ .../custom_file_preview.rb | 32 +++++++++++++ public/images/large/processing.gif | Bin 0 -> 24667 bytes 12 files changed, 113 insertions(+), 32 deletions(-) create mode 100644 lib/paperclip_processors/custom_file_preview.rb create mode 100644 public/images/large/processing.gif diff --git a/Dockerfile b/Dockerfile index 0eb255048..e46b0864d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,11 @@ FROM ruby:2.5.5 MAINTAINER BioSistemika +RUN echo deb "http://http.debian.net/debian stretch-backports main" >> /etc/apt/sources.list + # additional dependecies # libSSL-1.0 is required by wkhtmltopdf binary +# libreoffice for file preview generation RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - && \ apt-get update -qq && \ apt-get install -y \ @@ -14,6 +17,7 @@ RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - && \ unison \ sudo graphviz --no-install-recommends \ libfile-mimeinfo-perl && \ + apt-get install -y --no-install-recommends -t stretch-backports libreoffice && \ npm install -g yarn && \ rm -rf /var/lib/apt/lists/* diff --git a/Dockerfile.production b/Dockerfile.production index 3dc59885e..ca922f441 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -1,8 +1,11 @@ FROM ruby:2.5.5 MAINTAINER BioSistemika +RUN echo deb "http://http.debian.net/debian stretch-backports main" >> /etc/apt/sources.list + # additional dependecies # libSSL-1.0 is required by wkhtmltopdf binary +# libreoffice for file preview generation RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - && \ apt-get update -qq && \ apt-get install -y \ @@ -16,6 +19,7 @@ RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - && \ default-jre-headless \ sudo graphviz --no-install-recommends \ libfile-mimeinfo-perl && \ + apt-get install -y --no-install-recommends -t stretch-backports libreoffice && \ npm install -g yarn && \ rm -rf /var/lib/apt/lists/* diff --git a/Gemfile b/Gemfile index 0d04ba489..b82a4042d 100644 --- a/Gemfile +++ b/Gemfile @@ -79,8 +79,8 @@ gem 'underscore-rails' gem 'wicked_pdf', '~> 1.1.0' gem 'wkhtmltopdf-heroku' -gem 'aws-sdk', '~> 2' -gem 'paperclip', '~> 5.3' # File attachment, image attachment library +gem 'aws-sdk-s3' +gem 'paperclip', '~> 6.1' # File attachment, image attachment library gem 'delayed_job_active_record' gem 'devise-async', git: 'https://github.com/mhfs/devise-async.git', diff --git a/Gemfile.lock b/Gemfile.lock index cc6ec962c..d54892eb6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -102,13 +102,19 @@ GEM rails (>= 3.1) awesome_print (1.8.0) aws-eventstream (1.0.3) - aws-sdk (2.11.264) - aws-sdk-resources (= 2.11.264) - aws-sdk-core (2.11.264) - aws-sigv4 (~> 1.0) + aws-partitions (1.172.0) + aws-sdk-core (3.54.2) + aws-eventstream (~> 1.0, >= 1.0.2) + aws-partitions (~> 1.0) + aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-resources (2.11.264) - aws-sdk-core (= 2.11.264) + aws-sdk-kms (1.21.0) + aws-sdk-core (~> 3, >= 3.53.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.42.0) + aws-sdk-core (~> 3, >= 3.53.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.1) aws-sigv4 (1.1.0) aws-eventstream (~> 1.0, >= 1.0.2) backports (3.14.0) @@ -334,7 +340,7 @@ GEM overcommit (0.47.0) childprocess (~> 0.6, >= 0.6.3) iniparse (~> 1.4) - paperclip (5.3.0) + paperclip (6.1.0) activemodel (>= 4.2.0) activesupport (>= 4.2.0) mime-types @@ -550,7 +556,7 @@ DEPENDENCIES auto_strip_attributes (~> 2.1) autosize-rails awesome_print - aws-sdk (~> 2) + aws-sdk-s3 base62 bcrypt (~> 3.1.10) better_errors @@ -599,7 +605,7 @@ DEPENDENCIES omniauth omniauth-linkedin-oauth2 overcommit - paperclip (~> 5.3) + paperclip (~> 6.1) pg (~> 0.18) pg_search phantomjs diff --git a/app/models/asset.rb b/app/models/asset.rb index 9de1922f8..407a75889 100644 --- a/app/models/asset.rb +++ b/app/models/asset.rb @@ -10,16 +10,34 @@ class Asset < ApplicationRecord # Paperclip validation has_attached_file :file, - styles: { - large: [Constants::LARGE_PIC_FORMAT, :jpg], - medium: [Constants::MEDIUM_PIC_FORMAT, :jpg], - original: { processors: [:image_quality_calculate] } + styles: lambda { |a| + if a.previewable_document? + { + large: { processors: [:custom_file_preview], + geometry: Constants::LARGE_PIC_FORMAT, + format: :jpg }, + medium: { processors: [:custom_file_preview], + geometry: Constants::MEDIUM_PIC_FORMAT, + format: :jpg } + } + else + { + large: [Constants::LARGE_PIC_FORMAT, :jpg], + medium: [Constants::MEDIUM_PIC_FORMAT, :jpg], + original: { processors: [:image_quality_calculate] } + } + end }, convert_options: { medium: '-quality 70 -strip', all: '-background "#d2d2d2" -flatten +matte' } + before_post_process :previewable? + + # adds image processing in background job + process_in_background :file, processing_image_url: '/images/:style/processing.gif' + validates_attachment :file, presence: true, size: { @@ -31,20 +49,6 @@ class Asset < ApplicationRecord # Should be checked for any security leaks do_not_validate_attachment_file_type :file - # adds image processing in background job - process_in_background :file, - only_process: lambda { |a| - if a.content_type == - %r{^image/#{ Regexp.union( - Constants::WHITELISTED_IMAGE_TYPES - ) }} - %i(large medium original) - else - {} - end - }, - processing_image_url: '/images/:style/processing.gif' - # Asset validation # This could cause some problems if you create empty asset and want to # assign it to result @@ -196,6 +200,10 @@ class Asset < ApplicationRecord end end + def previewable? + file.previewable_image? || file.previewable_document? + end + def is_image? %r{^image/#{Regexp.union(Constants::WHITELISTED_IMAGE_TYPES)}} === file.content_type diff --git a/app/views/shared/_file_preview_icon.html.erb b/app/views/shared/_file_preview_icon.html.erb index 337f8969e..a37b5e60e 100644 --- a/app/views/shared/_file_preview_icon.html.erb +++ b/app/views/shared/_file_preview_icon.html.erb @@ -1,4 +1,8 @@
- + <% if asset.file.previewable_document? %> + <%= image_tag(asset.url(:large)) %> + <% else %> + + <% end %>

diff --git a/app/views/steps/attachments/_item.html.erb b/app/views/steps/attachments/_item.html.erb index fe01bf3c3..08c1be4b3 100644 --- a/app/views/steps/attachments/_item.html.erb +++ b/app/views/steps/attachments/_item.html.erb @@ -1,4 +1,4 @@ -<% if asset.file.processing? && asset.is_image? %> +<% if asset.file.processing? && (asset.is_image? || asset.file.previewable_document?) %> <% asset_status = 'asset-loading' %> <% present_url = step_file_present_asset_path(asset.id) %> <% else %> diff --git a/app/views/steps/attachments/_placeholder.html.erb b/app/views/steps/attachments/_placeholder.html.erb index ea845e888..c5c079c52 100644 --- a/app/views/steps/attachments/_placeholder.html.erb +++ b/app/views/steps/attachments/_placeholder.html.erb @@ -1,4 +1,4 @@ -<% image_preview = asset.is_image? ? asset.url(:medium) : nil %> +<% image_preview = (asset.is_image? || asset.file.previewable_document?) ? asset.url(:medium) : nil %>
diff --git a/config/initializers/constants.rb b/config/initializers/constants.rb index a8505c532..afdf18be1 100644 --- a/config/initializers/constants.rb +++ b/config/initializers/constants.rb @@ -218,6 +218,8 @@ class Constants 'text/plain' ].freeze + PREVIEWABLE_FILE_TYPES = TEXT_EXTRACT_FILE_TYPES + WHITELISTED_IMAGE_TYPES = [ 'gif', 'jpeg', 'pjpeg', 'png', 'x-png', 'svg+xml', 'bmp', 'tiff' ].freeze diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb index f032d32ca..868ff2c55 100644 --- a/config/initializers/paperclip.rb +++ b/config/initializers/paperclip.rb @@ -61,6 +61,27 @@ Paperclip::Attachment.class_eval do def fetch Paperclip.io_adapters.for self end + + def previewable_document? + previewable = Constants::PREVIEWABLE_FILE_TYPES.include?(content_type) + + extensions = %w(.xlsx .docx .pptx .xls .doc .ppt) + # Mimetype sometimes recognizes Office files as zip files + # In this case we also check the extension of the given file + # Otherwise the conversion should fail if the file is being something else + previewable ||= (content_type == 'application/zip' && extensions.include?(File.extname(original_filename))) + + # Mimetype also sometimes recognizes '.xls' and '.ppt' files as + # application/x-ole-storage (https://github.com/minad/mimemagic/issues/50) + previewable ||= + (content_type == 'application/x-ole-storage' && %w(.xls .ppt).include?(File.extname(original_filename))) + + previewable + end + + def previewable_image? + content_type == %r{^image/#{Regexp.union(Constants::WHITELISTED_IMAGE_TYPES)}} + end end module Paperclip diff --git a/lib/paperclip_processors/custom_file_preview.rb b/lib/paperclip_processors/custom_file_preview.rb new file mode 100644 index 000000000..256c4f261 --- /dev/null +++ b/lib/paperclip_processors/custom_file_preview.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Paperclip + class CustomFilePreview < Processor + def make + libreoffice_path = ENV['LIBREOFFICE_PATH'] || 'libreoffice' + directory = File.dirname(@file.path) + basename = File.basename(@file.path, '.*') + original_preview_file = File.join(directory, "#{basename}.png") + dst = TempfileFactory.new.generate("#{basename}.#{options[:format]}") + + begin + Paperclip.run( + libreoffice_path, + "--headless --invisible --convert-to png --outdir #{directory} #{@file.path}" + ) + + convert( + ":source -resize '#{options[:geometry]}' -format #{options[:format]} #{options[:convert_options]} :dest", + source: File.expand_path(original_preview_file), + dest: File.expand_path(dst.path) + ) + ensure + File.delete(original_preview_file) if File.file?(original_preview_file) + end + + dst + rescue StandardError => e + raise Paperclip::Error, "There was an error generating document preview - #{e}" + end + end +end diff --git a/public/images/large/processing.gif b/public/images/large/processing.gif new file mode 100644 index 0000000000000000000000000000000000000000..b58672fdf631ce8bc0b1c61ca4b22db9a0b1f2e6 GIT binary patch literal 24667 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 I|NRO4ACD(F