Project

General

Profile

Feature #8959 » 0001-Add-Microsoft-Office-documents-preview-via-MarkItDow.patch

Go MAEDA, 2026-02-26 23: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_markdown_converter_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 Microsoft Office documents
241
  #
242
  # Absolute path (e.g. /usr/local/bin/markitdown) to the MarkItDown command
243
  # used to convert supported attachments to Markdown for preview.
244
  #markdown_converter_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_markdown_converter_available: MarkItDown 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_markdown_converter_available: MarkItDownが利用可能 (オプション)
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
* MarkItDown (to enable preview for Microsoft Office documents)
23
  To install, run: `pip install 'markitdown[all]'`
22 24

  
23 25
Supported browsers:
24 26
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 'tempfile'
22
require 'timeout'
23

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

  
28
    COMMAND = (Redmine::Configuration['markdown_converter_command'] || 'markitdown').freeze
29
    MAX_PREVIEW_SIZE = 100.kilobytes
30
    SUPPORTED_EXTENSIONS = %w(.doc .docx .xls .xlsx .ppt .pptx).freeze
31

  
32
    def self.supports?(filename)
33
      SUPPORTED_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
      cmd = "#{shell_quote COMMAND} #{shell_quote source}"
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(cmd, out: output.path)
49
          _, status = Process.wait2(pid)
50
          unless status.success?
51
            logger.error("Markdown conversion failed (#{status.exitstatus}):\nCommand: #{cmd}")
52
            return nil
53
          end
54
        end
55
      rescue Timeout::Error
56
        Process.kill('KILL', pid) if pid
57
        logger.error("Markdown conversion timed out:\nCommand: #{cmd}")
58
        return nil
59
      rescue => e
60
        logger.error("Markdown conversion failed:\nCommand: #{cmd}\nException was: #{e.message}")
61
        return nil
62
      ensure
63
        output.close
64
      end
65

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

  
73
    def self.available?
74
      return @available if defined?(@available)
75

  
76
      begin
77
        `#{shell_quote COMMAND} --version`
78
        @available = $?.success?
79
      rescue
80
        @available = false
81
      end
82
      logger.warn("Markdown converter command (#{COMMAND}) not available") unless @available
83
      @available
84
    end
85

  
86
    def self.logger
87
      Rails.logger
88
    end
89
  end
90
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

  
253 275
  def test_show_other_with_no_preview
254 276
    @request.session[:user_id] = 2
255 277
    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_false_for_non_supported_extensions
547
    skip unless Redmine::Markdownizer.available?
548

  
549
    attachment = Attachment.new(
550
      :container => Issue.find(1),
551
      :file => uploaded_test_file("testfile.txt", "text/plain"),
552
      :author => User.find(1)
553
    )
554
    assert attachment.save
555
    assert_equal false, attachment.markdownized_previewable?
556
  end
557

  
558
  def test_delete_from_disk_should_delete_markdownized_preview_cache
559
    attachment = Attachment.create!(
560
      :container => Issue.find(1),
561
      :file => uploaded_test_file(
562
        "msword.docx",
563
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
564
      ),
565
      :author => User.find(1)
566
    )
567
    preview = attachment.markdownized_preview_cache_path
568
    FileUtils.mkdir_p(File.dirname(preview))
569
    File.write(preview, "preview")
570
    assert File.exist?(preview)
571

  
572
    attachment.send(:delete_from_disk!)
573
    assert_not File.exist?(preview)
574
  end
575

  
531 576
  if convert_installed?
532 577
    def test_thumbnail_should_generate_the_thumbnail
533 578
      set_fixtures_attachments_directory
(3-3/3)