From fd101dbf53133a42261951e58f9d076766f77e90 Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Thu, 26 Feb 2026 09:51:02 +0900 Subject: [PATCH 1/2] Add Microsoft Office documents preview via MarkItDown Markdown converter --- app/controllers/admin_controller.rb | 3 +- app/controllers/attachments_controller.rb | 2 + app/models/attachment.rb | 28 ++++++ app/views/attachments/markdownized.html.erb | 4 + config/configuration.yml.example | 6 ++ config/locales/en.yml | 1 + config/locales/ja.yml | 1 + doc/INSTALL | 2 + lib/redmine/markdownizer.rb | 90 ++++++++++++++++++ test/fixtures/files/msword.docx | Bin 0 -> 6786 bytes .../functional/attachments_controller_test.rb | 22 +++++ test/unit/attachment_test.rb | 45 +++++++++ 12 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 app/views/attachments/markdownized.html.erb create mode 100644 lib/redmine/markdownizer.rb create mode 100644 test/fixtures/files/msword.docx diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 9b45f9553..443e09ceb 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -79,7 +79,8 @@ class AdminController < ApplicationController [:text_all_migrations_have_been_run, !ActiveRecord::Base.connection.pool.migration_context.needs_migration?], [:text_minimagick_available, Object.const_defined?(:MiniMagick)], [:text_convert_available, Redmine::Thumbnail.convert_available?], - [:text_gs_available, Redmine::Thumbnail.gs_available?] + [:text_gs_available, Redmine::Thumbnail.gs_available?], + [:text_markdown_converter_available, Redmine::Markdownizer.available?] ] @checklist << [:text_default_active_job_queue_changed, Rails.application.config.active_job.queue_adapter != :async] if Rails.env.production? end diff --git a/app/controllers/attachments_controller.rb b/app/controllers/attachments_controller.rb index 18795891d..c4c9e0c16 100644 --- a/app/controllers/attachments_controller.rb +++ b/app/controllers/attachments_controller.rb @@ -63,6 +63,8 @@ class AttachmentsController < ApplicationController render :action => 'image' elsif @attachment.is_pdf? render :action => 'pdf' + elsif @content = @attachment.markdownized_preview_content + render :action => 'markdownized' else render :action => 'other' end diff --git a/app/models/attachment.rb b/app/models/attachment.rb index d0dd3a71f..de23ac51d 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -82,6 +82,8 @@ class Attachment < ApplicationRecord cattr_accessor :thumbnails_storage_path @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails") + cattr_accessor :markdownized_previews_storage_path + @@markdownized_previews_storage_path = File.join(Rails.root, "tmp", "markdownized_previews") before_create :files_to_final_location after_commit :delete_from_disk, :on => :destroy @@ -299,6 +301,31 @@ class Attachment < ApplicationRecord Redmine::MimeType.is_type?('audio', filename) end + def markdownized_previewable? + readable? && Redmine::Markdownizer.available? && Redmine::Markdownizer.supports?(filename) + end + + def markdownized_preview_content + return nil unless markdownized_previewable? + + target = markdownized_preview_cache_path + if Redmine::Markdownizer.convert(diskfile, target) + File.read(target, :mode => "rb") + end + rescue => e + if logger + logger.error( + "An error occured while generating markdownized preview for #{disk_filename} " \ + "to #{target}\nException was: #{e.message}" + ) + end + nil + end + + def markdownized_preview_cache_path + File.join(self.class.markdownized_previews_storage_path, "#{digest}_#{filesize}.md") + end + def previewable? is_text? || is_image? || is_video? || is_audio? end @@ -530,6 +557,7 @@ class Attachment < ApplicationRecord Dir[thumbnail_path("*")].each do |thumb| File.delete(thumb) end + FileUtils.rm_f(markdownized_preview_cache_path) end def thumbnail_path(size) diff --git a/app/views/attachments/markdownized.html.erb b/app/views/attachments/markdownized.html.erb new file mode 100644 index 000000000..3a158e827 --- /dev/null +++ b/app/views/attachments/markdownized.html.erb @@ -0,0 +1,4 @@ +<%= render :layout => 'layouts/file' do %> + <%= render :partial => 'common/markup', + :locals => {:markup_text_formatting => 'common_mark', :markup_text => @content} %> +<% end %> diff --git a/config/configuration.yml.example b/config/configuration.yml.example index e37f38745..10c5bc30d 100644 --- a/config/configuration.yml.example +++ b/config/configuration.yml.example @@ -237,6 +237,12 @@ default: # - example.org # - "*.example.com" + # Preview for Microsoft Office documents + # + # Absolute path (e.g. /usr/local/bin/markitdown) to the MarkItDown command + # used to convert supported attachments to Markdown for preview. + #markdown_converter_command: + # specific configuration options for production environment # that overrides the default ones production: diff --git a/config/locales/en.yml b/config/locales/en.yml index 17dfdfb6d..0fe69f63c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1335,6 +1335,7 @@ en: text_minimagick_available: MiniMagick available (optional) text_convert_available: ImageMagick convert available (optional) text_gs_available: ImageMagick PDF support available (optional) + text_markdown_converter_available: MarkItDown available (optional) text_default_active_job_queue_changed: Default queue adapter which is well suited only for dev/test changed text_destroy_time_entries_question: "%{hours} hours were reported on the issues you are about to delete. What do you want to do?" text_destroy_time_entries: Delete reported hours diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 53bbc362f..a8d8ad1e0 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -1103,6 +1103,7 @@ ja: notice_new_password_must_be_different: 新しいパスワードは現在のパスワードと異なるものでなければなりません setting_mail_handler_excluded_filenames: 除外する添付ファイル名 text_convert_available: ImageMagickのconvertコマンドが利用可能 (オプション) + text_markdown_converter_available: MarkItDownが利用可能 (オプション) label_link: リンク label_only: 次のもののみ label_drop_down_list: ドロップダウンリスト diff --git a/doc/INSTALL b/doc/INSTALL index 6d70f683a..8ce1fa14b 100644 --- a/doc/INSTALL +++ b/doc/INSTALL @@ -19,6 +19,8 @@ Optional: * SCM binaries (e.g. svn, git...), for repository browsing (must be available in PATH) * ImageMagick (to enable Gantt export to png images) +* MarkItDown (to enable preview for Microsoft Office documents) + To install, run: `pip install 'markitdown[all]'` Supported browsers: The current version of Firefox, Safari, Chrome, Chromium and Microsoft Edge. diff --git a/lib/redmine/markdownizer.rb b/lib/redmine/markdownizer.rb new file mode 100644 index 000000000..02cc5043e --- /dev/null +++ b/lib/redmine/markdownizer.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require 'fileutils' +require 'tempfile' +require 'timeout' + +module Redmine + module Markdownizer + extend Redmine::Utils::Shell + + COMMAND = (Redmine::Configuration['markdown_converter_command'] || 'markitdown').freeze + MAX_PREVIEW_SIZE = 100.kilobytes + SUPPORTED_EXTENSIONS = %w(.doc .docx .xls .xlsx .ppt .pptx).freeze + + def self.supports?(filename) + SUPPORTED_EXTENSIONS.include?(File.extname(filename.to_s).downcase) + end + + def self.convert(source, target) + return nil unless available? + return target if File.exist?(target) + + directory = File.dirname(target) + FileUtils.mkdir_p(directory) + cmd = "#{shell_quote COMMAND} #{shell_quote source}" + pid = nil + output = Tempfile.new('markdownized-preview') + + begin + Timeout.timeout(Redmine::Configuration['thumbnails_generation_timeout'].to_i) do + pid = Process.spawn(cmd, out: output.path) + _, status = Process.wait2(pid) + unless status.success? + logger.error("Markdown conversion failed (#{status.exitstatus}):\nCommand: #{cmd}") + return nil + end + end + rescue Timeout::Error + Process.kill('KILL', pid) if pid + logger.error("Markdown conversion timed out:\nCommand: #{cmd}") + return nil + rescue => e + logger.error("Markdown conversion failed:\nCommand: #{cmd}\nException was: #{e.message}") + return nil + ensure + output.close + end + + preview = File.binread(output.path, MAX_PREVIEW_SIZE + 1) || +"" + File.binwrite(target, preview.byteslice(0, MAX_PREVIEW_SIZE)) + target + ensure + output&.unlink + end + + def self.available? + return @available if defined?(@available) + + begin + `#{shell_quote COMMAND} --version` + @available = $?.success? + rescue + @available = false + end + logger.warn("Markdown converter command (#{COMMAND}) not available") unless @available + @available + end + + def self.logger + Rails.logger + end + end +end diff --git a/test/fixtures/files/msword.docx b/test/fixtures/files/msword.docx new file mode 100644 index 0000000000000000000000000000000000000000..2b438db969080ac54584eb280b8584e0f8254190 GIT binary patch literal 6786 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 ph5t)+`xF28W 'No preview available' end + def test_show_msword + skip unless Redmine::Markdownizer.available? + + set_tmp_attachments_directory + a = Attachment.new( + :container => Issue.find(1), + :file => uploaded_test_file( + 'msword.docx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ), + :author => User.find(1) + ) + assert a.save + + get(:show, :params => {:id => a.id}) + + assert_response :success + assert_equal 'text/html', @response.media_type + assert_select 'div.filecontent.wiki', :text => /Redmine is a flexible project management web application/ + assert_select '.nodata', :count => 0 + end + def test_show_other_with_no_preview @request.session[:user_id] = 2 get(:show, :params => {:id => 6}) diff --git a/test/unit/attachment_test.rb b/test/unit/attachment_test.rb index 7bfa9311f..91036e105 100644 --- a/test/unit/attachment_test.rb +++ b/test/unit/attachment_test.rb @@ -528,6 +528,51 @@ class AttachmentTest < ActiveSupport::TestCase assert_equal false, Attachment.new(:filename => 'test.txt').thumbnailable? end + def test_markdownized_previewable_should_be_true_for_supported_extensions + skip unless Redmine::Markdownizer.available? + + attachment = Attachment.new( + :container => Issue.find(1), + :file => uploaded_test_file( + "msword.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ), + :author => User.find(1) + ) + assert attachment.save + assert_equal true, attachment.markdownized_previewable? + end + + def test_markdownized_previewable_should_be_false_for_non_supported_extensions + skip unless Redmine::Markdownizer.available? + + attachment = Attachment.new( + :container => Issue.find(1), + :file => uploaded_test_file("testfile.txt", "text/plain"), + :author => User.find(1) + ) + assert attachment.save + assert_equal false, attachment.markdownized_previewable? + end + + def test_delete_from_disk_should_delete_markdownized_preview_cache + attachment = Attachment.create!( + :container => Issue.find(1), + :file => uploaded_test_file( + "msword.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ), + :author => User.find(1) + ) + preview = attachment.markdownized_preview_cache_path + FileUtils.mkdir_p(File.dirname(preview)) + File.write(preview, "preview") + assert File.exist?(preview) + + attachment.send(:delete_from_disk!) + assert_not File.exist?(preview) + end + if convert_installed? def test_thumbnail_should_generate_the_thumbnail set_fixtures_attachments_directory -- 2.50.1