#9 Active Storage - thumbnails

This commit is contained in:
Karel Pičman 2025-12-19 13:44:04 +01:00
parent 15384c61e4
commit 187c65248f
7 changed files with 87 additions and 99 deletions

View File

@ -20,6 +20,7 @@
source 'https://rubygems.org' do source 'https://rubygems.org' do
gem 'active_record_union' gem 'active_record_union'
gem 'activestorage' gem 'activestorage'
gem 'image_processing', '~> 1.2'
gem 'ox' # Dav4Rack gem 'ox' # Dav4Rack
gem 'rake' unless Dir.exist?(File.expand_path('../../redmine_dashboard', __FILE__)) gem 'rake' unless Dir.exist?(File.expand_path('../../redmine_dashboard', __FILE__))
gem 'simple_enum' gem 'simple_enum'

View File

@ -322,20 +322,6 @@ class DmsfFilesController < ApplicationController
redirect_to trash_dmsf_path(@project) redirect_to trash_dmsf_path(@project)
end end
def thumbnail
tbnail = @file.thumbnail(size: params[:size])
if tbnail
if stale?(etag: tbnail)
send_file tbnail,
filename: filename_for_content_disposition(@file.name),
type: @file.last_revision.content_type,
disposition: 'inline'
end
else
head :not_found
end
end
private private
def find_file def find_file

View File

@ -34,7 +34,7 @@ module DmsfHelper
def self.sanitize_filename(filename) def self.sanitize_filename(filename)
# Get only the filename, not the whole path # Get only the filename, not the whole path
just_filename = File.basename(filename.gsub('\\\\', '/')) just_filename = File.basename(filename.gsub('\\\\', '/'))
# Replace all non alphanumeric, hyphens or periods with underscore # Replace all non-alphanumeric, hyphens or periods with underscore
just_filename.gsub!(/[^\w.\-]/, '_') just_filename.gsub!(/[^\w.\-]/, '_')
# Keep the extension if any # Keep the extension if any
if !/^[a-zA-Z0-9_.\-]*$/.match?(just_filename) && just_filename =~ /(.[a-zA-Z0-9]+)$/ if !/^[a-zA-Z0-9_.\-]*$/.match?(just_filename) && just_filename =~ /(.[a-zA-Z0-9]+)$/

View File

@ -546,7 +546,7 @@ class DmsfFile < ApplicationRecord
end end
def thumbnailable? def thumbnailable?
Redmine::Thumbnail.convert_available? && (image? || (pdf? && Redmine::Thumbnail.gs_available?)) last_revision.file&.variable?
end end
def previewable? def previewable?
@ -632,29 +632,6 @@ class DmsfFile < ApplicationRecord
nil nil
end end
def thumbnail(options = {})
size = options[:size].to_i
if size.positive?
# Limit the number of thumbnails per image
size = (size / 50) * 50
# Maximum thumbnail size
size = 800 if size > 800
else
size = Setting.thumbnails_size.to_i
end
size = 100 unless size.positive?
target = File.join(Attachment.thumbnails_storage_path, "#{id}_#{last_revision.digest}_#{size}.thumb")
begin
Redmine::Thumbnail.generate last_revision.file.download, target, size, pdf?
rescue StandardError => e
Rails.logger.error do
%(An error occured while generating thumbnail for #{last_revision.file&.blob&.filename} to #{target}\n
Exception was: #{e.message})
end
nil
end
end
def locked_title def locked_title
if locked_for_user? if locked_for_user?
return l(:title_locked_by_user, user: lock.reverse[0].user) if lock.reverse[0].user return l(:title_locked_by_user, user: lock.reverse[0].user) if lock.reverse[0].user

View File

@ -25,12 +25,20 @@
<div class="thumbnails"> <div class="thumbnails">
<% end %> <% end %>
<% images.each do |file| %> <% images.each do |file| %>
<div> <div class="thumbnail" title="<%= file.name %>">
<% if link_to # Redmine classic %> <% if link_to # Redmine classic %>
<%= link_to image_tag(dmsf_thumbnail_path(file), alt: file.title), view_dmsf_file_url(file) %> <% size = Setting.thumbnails_size.to_i %>
<%= link_to image_tag(file.last_revision&.file&.variant(resize_to_limit: [size, size]),
alt: file.title,
style: "max-width: #{size}px; max-height: #{size}px;",
loading: 'lazy'),
view_dmsf_file_url(file) %>
<% else # jQuery gallery %> <% else # jQuery gallery %>
<%= image_tag(dmsf_thumbnail_path(file), <%= image_tag(file.last_revision&.file&.variant(resize_to_limit: [size, size]),
{ :'data-fullsrc' => view_dmsf_file_url(file), alt: file.title }) %> :'data-fullsrc' => view_dmsf_file_url(file),
alt: file.title,
style: "max-width: #{size}px; max-height: #{size}px;",
loading: 'lazy') %>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>

View File

@ -241,16 +241,22 @@ module RedmineDmsf
{{dmsftn(file_id)}} -- with default height 200 (auto width) {{dmsftn(file_id)}} -- with default height 200 (auto width)
{{dmsftn(file_id1 file_id2 file_id3)}} -- multiple thumbnails {{dmsftn(file_id1 file_id2 file_id3)}} -- multiple thumbnails
{{dmsftn(file_id, size=300)}} -- with size 300x300 {{dmsftn(file_id, size=300)}} -- with size 300x300
{{dmsftn(file_id, height=300)}} -- with height (auto width) {{dmsftn(file_id, height=300)}} -- with height (default width)
{{dmsftn(file_id, width=300)}} -- with width (auto height) {{dmsftn(file_id, width=300)}} -- with width (default height)
{{dmsftn(file_id, size=640x480)}} -- with size 640x480} {{dmsftn(file_id, size=640x480)}} -- with size 640x480}
macro :dmsftn do |_obj, args| macro :dmsftn do |_obj, args|
raise ArgumentError if args.empty? # Requires file id raise ArgumentError if args.empty? # Requires file id
args, options = extract_macro_options(args, :size, :width, :height, :title) args, options = extract_macro_options(args, :size, :width, :height, :title)
size = options[:size]
width = options[:width] if options[:size].present?
height = options[:height] width, height = options[:size].split('x')
height = width if height.blank?
else
width = options[:width].presence || Setting.thumbnails_size.to_i
height = options[:height].presence || Setting.thumbnails_size.to_i
end
ids = args[0].split ids = args[0].split
html = [] html = []
ids.each do |id| ids.each do |id|
@ -260,21 +266,17 @@ module RedmineDmsf
next next
end end
raise ::I18n.t(:notice_not_authorized) unless User.current&.allowed_to?(:view_dmsf_files, file.project) raise ::I18n.t(:notice_not_authorized) unless User.current&.allowed_to?(:view_dmsf_files, file.project)
raise ::I18n.t(:error_not_supported_image_format) unless file.image? raise ::I18n.t(:error_not_supported_image_format) unless file&.thumbnailable?
member = Member.find_by(user_id: User.current.id, project_id: file.project.id) member = Member.find_by(user_id: User.current.id, project_id: file.project.id)
filename = file.last_revision.formatted_name(member) filename = file.last_revision.formatted_name(member)
url = static_dmsf_file_url(file, filename: filename) url = static_dmsf_file_url(file, filename: filename)
img = if size img = image_tag(file.last_revision&.file&.variant(resize_to_limit: [width, height]),
image_tag(url, alt: filename, title: file.title, size: size) alt: filename,
elsif height style: "max-width: #{width}px; max-height: #{height}px;",
image_tag(url, alt: filename, title: file.title, width: 'auto', height: height) loading: 'lazy')
elsif width html << link_to(img,
image_tag(url, alt: filename, title: file.title, width: width, height: 'auto') url,
else
image_tag(url, alt: filename, title: file.title, width: 'auto', height: 200)
end
html << link_to(img, url,
target: '_blank', target: '_blank',
rel: 'noopener', rel: 'noopener',
title: h(file.last_revision.try(:tooltip)), title: h(file.last_revision.try(:tooltip)),

View File

@ -254,21 +254,23 @@ class DmsfMacrosTest < RedmineDmsf::Test::HelperTest
size = '50%' size = '50%'
url = static_dmsf_file_url(@file7, @file7.last_revision.name) url = static_dmsf_file_url(@file7, @file7.last_revision.name)
text = textilizable("{{dmsf_image(#{@file7.id}, size=#{size})}}") text = textilizable("{{dmsf_image(#{@file7.id}, size=#{size})}}")
assert text.include?(image_tag(url, alt: @file7.name, title: @file7.title, width: size, height: size)), text assert_equal "<p>#{image_tag(url, alt: @file7.name, title: @file7.title, width: size, height: size)}</p>", text
# TODO: arguments src and with and height are swapped # TODO: Swaped parameters src and size
# size = '300' # size = '300'
# text = textilizable("{{dmsf_image(#{@file7.id}, size=#{size})}}") # text = textilizable("{{dmsf_image(#{@file7.id}, size=#{size})}}")
# assert text.include?(image_tag(url, alt: @file7.name, title: @file7.title, width: size, height: size)), text # assert_equal "<p>#{image_tag(url, alt: @file7.name, title: @file7.title, width: size, height: size)}</p>", text
# TODO: arguments src and with and height are swapped
# size = '640x480' # size = '640x480'
# text = textilizable("{{dmsf_image(#{@file7.id}, size=#{size})}}") # text = textilizable("{{dmsf_image(#{@file7.id}, size=#{size})}}")
# assert text.include?(image_tag(url, alt: @file7.name, title: @file7.title, width: '640', height: '480')), text # assert_equal "<p>#{image_tag(url, alt: @file7.name, title: @file7.title, width: '640', height: '480')}</p>",
# text
height = '480' height = '480'
text = textilizable("{{dmsf_image(#{@file7.id}, height=#{height})}}") text = textilizable("{{dmsf_image(#{@file7.id}, height=#{height})}}")
assert text.include?(image_tag(url, alt: @file7.name, title: @file7.title, width: 'auto', height: height)), text assert_equal "<p>#{image_tag(url, alt: @file7.name, title: @file7.title, width: 'auto', height: height)}</p>",
text
width = '480' width = '480'
text = textilizable("{{dmsf_image(#{@file7.id}, width=#{height})}}") text = textilizable("{{dmsf_image(#{@file7.id}, width=#{height})}}")
assert text.include?(image_tag(url, alt: @file7.name, title: @file7.title, width: width, height: 'auto')), text assert_equal "<p>#{image_tag(url, alt: @file7.name, title: @file7.title, width: width, height: 'auto')}</p>",
text
end end
def test_macro_dmsf_image_no_permissions def test_macro_dmsf_image_no_permissions
@ -344,74 +346,86 @@ class DmsfMacrosTest < RedmineDmsf::Test::HelperTest
def test_macro_dmsftn def test_macro_dmsftn
text = textilizable("{{dmsftn(#{@file7.id})}}") text = textilizable("{{dmsftn(#{@file7.id})}}")
url = static_dmsf_file_url(@file7, @file7.last_revision.name) url = static_dmsf_file_url(@file7, @file7.last_revision.name)
img = image_tag(url, alt: @file7.name, title: @file7.title, width: 'auto', height: 200) size = Setting.thumbnails_size.to_i
img = image_tag(@file7.last_revision&.file&.variant(resize_to_limit: [size, size]),
alt: @file7.name,
style: "max-width: #{size}px; max-height: #{size}px;",
loading: 'lazy')
link = link_to(img, link = link_to(img,
url, url,
target: '_blank', target: '_blank',
rel: 'noopener', rel: 'noopener',
title: h(@file7.last_revision.try(:tooltip)), title: h(@file7.last_revision.try(:tooltip)),
'data-downloadurl' => "#{@file7.last_revision.content_type}:#{h(@file7.name)}:#{url}") 'data-downloadurl' => "#{@file7.last_revision.content_type}:#{h(@file7.name)}:#{url}")
assert text.include?(link), text assert_equal "<p>#{link}</p>", text
end end
# {{dmsftn(file_id file_id)}} # {{dmsftn(file_id file_id)}}
def test_macro_dmsftn_multiple def test_macro_dmsftn_multiple
text = textilizable("{{dmsftn(#{@file7.id} #{@file7.id})}}") text = textilizable("{{dmsftn(#{@file7.id} #{@file7.id})}}")
url = static_dmsf_file_url(@file7, @file7.last_revision.name) url = static_dmsf_file_url(@file7, @file7.last_revision.name)
img = image_tag(url, alt: @file7.name, title: @file7.title, width: 'auto', height: 200) img = image_tag(@file7.last_revision&.file&.variant(resize_to_limit: [100, 100]),
alt: @file7.name,
style: 'max-width: 100px; max-height: 100px;',
loading: 'lazy')
link = link_to(img, link = link_to(img,
url, url,
target: '_blank', target: '_blank',
rel: 'noopener', rel: 'noopener',
title: h(@file7.last_revision.try(:tooltip)), title: h(@file7.last_revision.try(:tooltip)),
'data-downloadurl': 'image/gif:test.gif:http://www.example.com/dmsf/files/7/test.gif') 'data-downloadurl': 'image/gif:test.gif:http://www.example.com/dmsf/files/7/test.gif')
assert text.include?(link + link), text assert_equal "<p>#{link}#{link}</p>", text
end end
# {{dmsftn(file_id size=300)}} # {{dmsftn(file_id size=300)}}
def test_macro_dmsftn_size def test_macro_dmsftn_size
url = static_dmsf_file_url(@file7, @file7.last_revision.name) url = static_dmsf_file_url(@file7, @file7.last_revision.name)
size = '300' size = Setting.thumbnails_size.to_i
text = textilizable("{{dmsftn(#{@file7.id}, size=#{size})}}")
img = image_tag(url, alt: @file7.name, title: @file7.title, size: size) # Size
text = textilizable("{{dmsftn(#{@file7.id}, size=300)}}")
img = image_tag(@file7.last_revision&.file&.variant(resize_to_limit: [300, 300]),
alt: @file7.name,
style: 'max-width: 300px; max-height: 300px;',
loading: 'lazy')
link = link_to(img, link = link_to(img,
url, url,
target: '_blank', target: '_blank',
rel: 'noopener', rel: 'noopener',
title: h(@file7.last_revision.try(:tooltip)), title: h(@file7.last_revision.try(:tooltip)),
'data-downloadurl' => "#{@file7.last_revision.content_type}:#{h(@file7.name)}:#{url}") 'data-downloadurl' => "#{@file7.last_revision.content_type}:#{h(@file7.name)}:#{url}")
assert text.include?(link), text assert_equal "<p>#{link.gsub(/redirect\/.*\/#{@file7.name}/, '...')}</p>",
# TODO: arguments src and with and height are swapped text.gsub(/redirect\/.*\/#{@file7.name}/, '...')
# size = '640x480'
# text = textilizable("{{dmsftn(#{@file7.id}, size=#{size})}}") # Height
# img = image_tag(url, alt: @file7.name, title: @file7.title, width: 640, height: 480) text = textilizable("{{dmsftn(#{@file7.id}, height=480)}}")
# link = link_to(img, img = image_tag(@file7.last_revision&.file&.variant(resize_to_limit: [size, 480]),
# url, alt: @file7.name,
# target: '_blank', style: "max-width: #{size}px; max-height: 480px;",
# rel: 'noopener', loading: 'lazy')
# title: h(@file7.last_revision.try(:tooltip)),
# 'data-downloadurl' => "#{@file7.last_revision.content_type}:#{h(@file7.name)}:#{url}")
# assert text.include?(link), text
height = '480'
text = textilizable("{{dmsftn(#{@file7.id}, height=#{height})}}")
img = image_tag(url, alt: @file7.name, title: @file7.title, width: 'auto', height: 480)
link = link_to(img,
url,
target: '_blank',
rel: 'noopener',
title: h(@file7.last_revision.try(:tooltip)),
'data-downloadurl': 'image/gif:test.gif:http://www.example.com/dmsf/files/7/test.gif')
assert text.include?(link), text
width = '640'
text = textilizable("{{dmsftn(#{@file7.id}, width=#{width})}}")
img = image_tag(url, alt: @file7.name, title: @file7.title, width: 640, height: 'auto')
link = link_to(img, link = link_to(img,
url, url,
target: '_blank', target: '_blank',
rel: 'noopener', rel: 'noopener',
title: h(@file7.last_revision.try(:tooltip)), title: h(@file7.last_revision.try(:tooltip)),
'data-downloadurl' => "#{@file7.last_revision.content_type}:#{h(@file7.name)}:#{url}") 'data-downloadurl' => "#{@file7.last_revision.content_type}:#{h(@file7.name)}:#{url}")
assert text.include?(link), text assert_equal "<p>#{link.gsub(/redirect\/.*\/#{@file7.name}/, '...')}</p>",
text.gsub(/redirect\/.*\/#{@file7.name}/, '...')
# Width
text = textilizable("{{dmsftn(#{@file7.id}, width=640)}}")
img = image_tag(@file7.last_revision&.file&.variant(resize_to_limit: [640, size]),
alt: @file7.name,
style: "max-width: 640px; max-height: #{size}px;",
loading: 'lazy')
link = link_to(img,
url,
target: '_blank',
rel: 'noopener',
title: h(@file7.last_revision.try(:tooltip)),
'data-downloadurl' => "#{@file7.last_revision.content_type}:#{h(@file7.name)}:#{url}")
assert_equal "<p>#{link.gsub(/redirect\/.*\/#{@file7.name}/, '...')}</p>",
text.gsub(/redirect\/.*\/#{@file7.name}/, '...')
end end
def test_macro_dmsftn_no_permissions def test_macro_dmsftn_no_permissions