Project

General

Profile

Feature #8959 » 0001-Add-preview-support-for-Microsoft-Office-and-LibreOf.patch

Go MAEDA, 2026-03-03 07:19

View differences:

app/controllers/admin_controller.rb
79 79
      [:text_all_migrations_have_been_run, !ActiveRecord::Base.connection.pool.migration_context.needs_migration?],
80 80
      [:text_minimagick_available,     Object.const_defined?(:MiniMagick)],
81 81
      [:text_convert_available,        Redmine::Thumbnail.convert_available?],
82
      [:text_gs_available,             Redmine::Thumbnail.gs_available?]
82
      [:text_gs_available,             Redmine::Thumbnail.gs_available?],
83
      [:text_pandoc_available, Redmine::Markdownizer.available?]
83 84
    ]
84 85
    @checklist << [:text_default_active_job_queue_changed, Rails.application.config.active_job.queue_adapter != :async] if Rails.env.production?
85 86
  end
app/controllers/attachments_controller.rb
63 63
          render :action => 'image'
64 64
        elsif @attachment.is_pdf?
65 65
          render :action => 'pdf'
66
        elsif @content = @attachment.markdownized_preview_content
67
          render :action => 'markdownized'
66 68
        else
67 69
          render :action => 'other'
68 70
        end
app/models/attachment.rb
82 82

  
83 83
  cattr_accessor :thumbnails_storage_path
84 84
  @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
85
  cattr_accessor :markdownized_previews_storage_path
86
  @@markdownized_previews_storage_path = File.join(Rails.root, "tmp", "markdownized_previews")
85 87

  
86 88
  before_create :files_to_final_location
87 89
  after_commit :delete_from_disk, :on => :destroy
......
299 301
    Redmine::MimeType.is_type?('audio', filename)
300 302
  end
301 303

  
304
  def markdownized_previewable?
305
    readable? && Redmine::Markdownizer.available? && Redmine::Markdownizer.supports?(filename)
306
  end
307

  
308
  def markdownized_preview_content
309
    return nil unless markdownized_previewable?
310

  
311
    target = markdownized_preview_cache_path
312
    if Redmine::Markdownizer.convert(diskfile, target)
313
      File.read(target, :mode => "rb")
314
    end
315
  rescue => e
316
    if logger
317
      logger.error(
318
        "An error occured while generating markdownized preview for #{disk_filename} " \
319
          "to #{target}\nException was: #{e.message}"
320
      )
321
    end
322
    nil
323
  end
324

  
325
  def markdownized_preview_cache_path
326
    File.join(self.class.markdownized_previews_storage_path, "#{digest}_#{filesize}.md")
327
  end
328

  
302 329
  def previewable?
303 330
    is_text? || is_image? || is_video? || is_audio?
304 331
  end
......
530 557
    Dir[thumbnail_path("*")].each do |thumb|
531 558
      File.delete(thumb)
532 559
    end
560
    FileUtils.rm_f(markdownized_preview_cache_path)
533 561
  end
534 562

  
535 563
  def thumbnail_path(size)
app/views/attachments/markdownized.html.erb
1
<%= render :layout => 'layouts/file' do %>
2
  <%= render :partial => 'common/markup',
3
             :locals => {:markup_text_formatting => 'common_mark', :markup_text => @content} %>
4
<% end %>
config/configuration.yml.example
237 237
  #   - example.org
238 238
  #   - "*.example.com"
239 239

  
240
  # Preview for Office documents (Microsoft Office / LibreOffice)
241
  #
242
  # Absolute path (e.g. /usr/local/bin/pandoc) to the Pandoc command
243
  # used to convert supported attachments to Markdown for preview.
244
  #pandoc_command:
245

  
240 246
# specific configuration options for production environment
241 247
# that overrides the default ones
242 248
production:
config/locales/en.yml
1335 1335
  text_minimagick_available: MiniMagick available (optional)
1336 1336
  text_convert_available: ImageMagick convert available (optional)
1337 1337
  text_gs_available: ImageMagick PDF support available (optional)
1338
  text_pandoc_available: Pandoc available (optional)
1338 1339
  text_default_active_job_queue_changed: Default queue adapter which is well suited only for dev/test changed
1339 1340
  text_destroy_time_entries_question: "%{hours} hours were reported on the issues you are about to delete. What do you want to do?"
1340 1341
  text_destroy_time_entries: Delete reported hours
config/locales/ja.yml
1103 1103
  notice_new_password_must_be_different: 新しいパスワードは現在のパスワードと異なるものでなければなりません
1104 1104
  setting_mail_handler_excluded_filenames: 除外する添付ファイル名
1105 1105
  text_convert_available: ImageMagickのconvertコマンドが利用可能 (オプション)
1106
  text_pandoc_available: Pandocが利用可能 (オプション)
1106 1107
  label_link: リンク
1107 1108
  label_only: 次のもののみ
1108 1109
  label_drop_down_list: ドロップダウンリスト
doc/INSTALL
19 19
* SCM binaries (e.g. svn, git...), for repository browsing (must be
20 20
  available in PATH)
21 21
* ImageMagick (to enable Gantt export to png images)
22
* Pandoc, to enable previews for Microsoft Office (.docx, .xlsx, .pptx) and
23
  LibreOffice Writer (.odt) documents. Version 3.8.3 or later is recommended.
24
  Older versions of Pandoc supports only .docx and .odt documents.
22 25

  
23 26
Supported browsers:
24 27
The current version of Firefox, Safari, Chrome, Chromium and Microsoft Edge.
lib/redmine/markdownizer.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require 'fileutils'
21
require 'shellwords'
22
require 'tempfile'
23
require 'timeout'
24

  
25
module Redmine
26
  module Markdownizer
27
    extend Redmine::Utils::Shell
28

  
29
    COMMAND = (Redmine::Configuration['pandoc_command'] || 'pandoc').freeze
30
    MAX_PREVIEW_SIZE = 100.kilobytes
31

  
32
    def self.supports?(filename)
33
      markdownizable_extensions.include?(File.extname(filename.to_s).downcase)
34
    end
35

  
36
    def self.convert(source, target)
37
      return nil unless available?
38
      return target if File.exist?(target)
39

  
40
      directory = File.dirname(target)
41
      FileUtils.mkdir_p(directory)
42
      args = [COMMAND, source, "-t", "gfm"]
43
      pid = nil
44
      output = Tempfile.new('markdownized-preview')
45

  
46
      begin
47
        Timeout.timeout(Redmine::Configuration['thumbnails_generation_timeout'].to_i) do
48
          pid = Process.spawn(*args, out: output.path)
49
          _, status = Process.wait2(pid)
50
          unless status.success?
51
            logger.error("Markdown conversion failed (#{status.exitstatus}):\nCommand: #{args.shelljoin}")
52
            return nil
53
          end
54
        end
55
      rescue Timeout::Error
56
        if pid
57
          Process.kill('KILL', pid)
58
          Process.detach(pid)
59
        end
60
        logger.error("Markdown conversion timed out:\nCommand: #{args.shelljoin}")
61
        return nil
62
      rescue => e
63
        logger.error("Markdown conversion failed:\nCommand: #{args.shelljoin}\nException was: #{e.message}")
64
        return nil
65
      ensure
66
        output.close
67
      end
68

  
69
      preview = File.binread(output.path, MAX_PREVIEW_SIZE + 1) || +""
70
      File.binwrite(target, preview.byteslice(0, MAX_PREVIEW_SIZE))
71
      target
72
    ensure
73
      output&.unlink
74
    end
75

  
76
    def self.available?
77
      return @available if defined?(@available)
78

  
79
      begin
80
        @pandoc_version = `#{shell_quote COMMAND} --version`[/pandoc\s+([\d.]+)/, 1].split('.').map(&:to_i)
81
        @available = $?.success?
82
      rescue
83
        @available = false
84
      end
85
      logger.warn("Markdown converter command (#{COMMAND}) not available") unless @available
86
      @available
87
    end
88

  
89
    def self.markdownizable_extensions
90
      return @markdownizable_extensions if defined?(@markdownizable_extensions)
91

  
92
      if available?
93
        # Microsoft Word and LibreOffice Writer files are supported by a wide
94
        # range of Pandoc versions
95
        @markdownizable_extensions = %w[.docx .odt]
96
      else
97
        return (@markdownizable_extensions = [])
98
      end
99
      # Pandoc >= 3.8.3 supports Microsoft Excel and PowerPoint files
100
      @markdownizable_extensions += %w[.xlsx .pptx] if (@pandoc_version <=> [3, 8, 3]) >= 0
101

  
102
      @markdownizable_extensions
103
    end
104

  
105
    def self.logger
106
      Rails.logger
107
    end
108
  end
109
end
test/functional/attachments_controller_test.rb
250 250
    assert_select '.nodata', :text => 'No preview available'
251 251
  end
252 252

  
253
  def test_show_msword
254
    skip unless Redmine::Markdownizer.available?
255

  
256
    set_tmp_attachments_directory
257
    a = Attachment.new(
258
      :container => Issue.find(1),
259
      :file => uploaded_test_file(
260
        'msword.docx',
261
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
262
      ),
263
      :author => User.find(1)
264
    )
265
    assert a.save
266

  
267
    get(:show, :params => {:id => a.id})
268

  
269
    assert_response :success
270
    assert_equal 'text/html', @response.media_type
271
    assert_select 'div.filecontent.wiki', :text => /Redmine is a flexible project management web application/
272
    assert_select '.nodata', :count => 0
273
  end
274

  
275
  def test_show_libreoffice_writer
276
    skip unless Redmine::Markdownizer.available?
277

  
278
    set_tmp_attachments_directory
279
    a = Attachment.new(
280
      :container => Issue.find(1),
281
      :file => uploaded_test_file(
282
        'libreoffice-writer.odt',
283
        'application/vnd.oasis.opendocument.text'
284
      ),
285
      :author => User.find(1)
286
    )
287
    assert a.save
288

  
289
    get(:show, :params => {:id => a.id})
290

  
291
    assert_response :success
292
    assert_equal 'text/html', @response.media_type
293
    assert_select 'div.filecontent.wiki', :text => /Redmine is a flexible project management web application/
294
    assert_select '.nodata', :count => 0
295
  end
296

  
253 297
  def test_show_other_with_no_preview
254 298
    @request.session[:user_id] = 2
255 299
    get(:show, :params => {:id => 6})
test/unit/attachment_test.rb
528 528
    assert_equal false, Attachment.new(:filename => 'test.txt').thumbnailable?
529 529
  end
530 530

  
531
  def test_markdownized_previewable_should_be_true_for_supported_extensions
532
    skip unless Redmine::Markdownizer.available?
533

  
534
    attachment = Attachment.new(
535
      :container => Issue.find(1),
536
      :file => uploaded_test_file(
537
        "msword.docx",
538
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
539
      ),
540
      :author => User.find(1)
541
    )
542
    assert attachment.save
543
    assert_equal true, attachment.markdownized_previewable?
544
  end
545

  
546
  def test_markdownized_previewable_should_be_true_for_supported_libreoffice_extensions
547
    skip unless Redmine::Markdownizer.available?
548

  
549
    attachment = Attachment.new(
550
      :container => Issue.find(1),
551
      :file => uploaded_test_file(
552
        "libreoffice-writer.odt",
553
        "application/vnd.oasis.opendocument.text"
554
      ),
555
      :author => User.find(1)
556
    )
557
    assert attachment.save
558
    assert_equal true, attachment.markdownized_previewable?
559
  end
560

  
561
  def test_markdownized_previewable_should_be_false_for_non_supported_extensions
562
    skip unless Redmine::Markdownizer.available?
563

  
564
    attachment = Attachment.new(
565
      :container => Issue.find(1),
566
      :file => uploaded_test_file("testfile.txt", "text/plain"),
567
      :author => User.find(1)
568
    )
569
    assert attachment.save
570
    assert_equal false, attachment.markdownized_previewable?
571
  end
572

  
573
  def test_delete_from_disk_should_delete_markdownized_preview_cache
574
    attachment = Attachment.create!(
575
      :container => Issue.find(1),
576
      :file => uploaded_test_file(
577
        "msword.docx",
578
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
579
      ),
580
      :author => User.find(1)
581
    )
582
    preview = attachment.markdownized_preview_cache_path
583
    FileUtils.mkdir_p(File.dirname(preview))
584
    File.write(preview, "preview")
585
    assert File.exist?(preview)
586

  
587
    attachment.send(:delete_from_disk!)
588
    assert_not File.exist?(preview)
589
  end
590

  
531 591
  if convert_installed?
532 592
    def test_thumbnail_should_generate_the_thumbnail
533 593
      set_fixtures_attachments_directory
(6-6/9)