Refactors parse_hires_images and parse_inline_attachments methods from ApplicationHelper to scrubbers (#43745).

git-svn-id: https://svn.redmine.org/redmine/trunk@24406 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Marius Balteanu
2026-02-12 02:52:28 +00:00
parent ea918839f8
commit 8bf7f09112
8 changed files with 160 additions and 67 deletions

View File

@@ -914,7 +914,7 @@ module ApplicationHelper
return '' if text.blank?
project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
@only_path = only_path = options.delete(:only_path) == false ? false : true
@only_path = only_path = options[:only_path] = (options[:only_path] != false)
text = text.dup
macros = catch_macros(text)
@@ -923,7 +923,7 @@ module ApplicationHelper
text = h(text)
else
formatting = Setting.text_formatting
text = Redmine::WikiFormatting.to_html(formatting, text, :object => obj, :attribute => attr)
text = Redmine::WikiFormatting.to_html(formatting, text, options.merge(:object => obj, :attribute => attr, :view => self))
end
@parsed_headings = []
@@ -932,7 +932,7 @@ module ApplicationHelper
parse_sections(text, project, obj, attr, only_path, options)
text = parse_non_pre_blocks(text, obj, macros, options) do |txt|
[:parse_inline_attachments, :parse_hires_images, :parse_wiki_links, :parse_redmine_links].each do |method_name|
[:parse_wiki_links, :parse_redmine_links].each do |method_name|
send method_name, txt, project, obj, attr, only_path, options
end
end
@@ -977,54 +977,6 @@ module ApplicationHelper
parsed
end
# add srcset attribute to img tags if filename includes @2x, @3x, etc.
# to support hires displays
def parse_hires_images(text, project, obj, attr, only_path, options)
text.gsub!(/src="([^"]+@(\dx)\.(bmp|gif|jpg|jpe|jpeg|png))"/i) do |m|
filename, dpr = $1, $2
m + " srcset=\"#{filename} #{dpr}\""
end
end
def parse_inline_attachments(text, project, obj, attr, only_path, options)
return if options[:inline_attachments] == false
# when using an image link, try to use an attachment, if possible
attachments = options[:attachments] || []
if obj.is_a?(Journal)
attachments += obj.journalized.attachments if obj.journalized.respond_to?(:attachments)
else
attachments += obj.attachments if obj.respond_to?(:attachments)
end
if attachments.present?
title_and_alt_re = /\s+(title|alt)="([^"]*)"/i
text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png|webp))"([^>]*)/i) do |m|
filename, ext, other_attrs = $1, $2, $3
# search for the picture in attachments
if found = Attachment.latest_attach(attachments, CGI.unescape(filename))
image_url = download_named_attachment_url(found, found.filename, :only_path => only_path)
desc = found.description.to_s.delete('"')
# remove title and alt attributes after extracting them
title_and_alt = other_attrs.scan(title_and_alt_re).to_h
other_attrs.gsub!(title_and_alt_re, '')
title_and_alt_attrs = if !desc.blank? && title_and_alt['alt'].blank?
" title=\"#{desc}\" alt=\"#{desc}\""
else
# restore original title and alt attributes
" #{title_and_alt.map { |k, v| %[#{k}="#{v}"] }.join(' ')}"
end
"src=\"#{image_url}\"#{title_and_alt_attrs} loading=\"lazy\"#{other_attrs}"
else
m
end
end
end
end
# Wiki links
#
# Examples:

View File

@@ -93,10 +93,10 @@ module Redmine
# Text retrieved from the cache store may be frozen
# We need to dup it so we can do in-place substitutions with gsub!
cache_store.fetch cache_key do
formatter_for(format).new(text).to_html
formatter_for(format).new(text, options).to_html
end.dup
else
formatter_for(format).new(text).to_html
formatter_for(format).new(text, options).to_html
end
text
end
@@ -127,8 +127,9 @@ module Redmine
include ActionView::Helpers::UrlHelper
include Redmine::WikiFormatting::LinksHelper
def initialize(text)
def initialize(text, options = {})
@text = text
@options = options
end
def to_html(*args)

View File

@@ -65,8 +65,9 @@ module Redmine
class Formatter
include Redmine::WikiFormatting::SectionHelper
def initialize(text)
def initialize(text, options = {})
@text = text
@options = options
end
def to_html(*args)
@@ -75,7 +76,7 @@ module Redmine
SANITIZER.call(fragment)
scrubber = Loofah::Scrubber.new do |node|
SCRUBBERS.each do |s|
(SCRUBBERS + post_processor_scrubbers).each do |s|
result = s.scrub(node)
break result if result == Loofah::Scrubber::STOP
break if node.parent.nil?
@@ -85,6 +86,15 @@ module Redmine
fragment.scrub!(scrubber)
fragment.to_s
end
private
def post_processor_scrubbers
[
Redmine::WikiFormatting::InlineAttachmentsScrubber.new(@options),
Redmine::WikiFormatting::HiresImagesScrubber.new
]
end
end
end
end

View File

@@ -0,0 +1,41 @@
# 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.
module Redmine
module WikiFormatting
class HiresImagesScrubber < Loofah::Scrubber
HIRES_FILENAME_REGEX = /@(?<dpr>\dx)\.(?:bmp|gif|jpg|jpe|jpeg|png)\z/i
def scrub(node)
return unless node.name == 'img' && node['src'].present?
src = node['src']
return unless src.include?('@')
match = src.match(HIRES_FILENAME_REGEX)
return unless match
# Set the srcset attribute.
node['srcset'] = "#{src} #{match[:dpr]}"
end
end
end
end

View File

@@ -0,0 +1,79 @@
# 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.
module Redmine
module WikiFormatting
class InlineAttachmentsScrubber < Loofah::Scrubber
def initialize(options = {})
super()
@options = options
@obj = options[:object]
@view = options[:view]
@only_path = options[:only_path]
@attachments = options[:attachments] || []
if @obj.is_a?(Journal)
@attachments += @obj.journalized.attachments if @obj.journalized.respond_to?(:attachments)
elsif @obj.respond_to?(:attachments)
@attachments += @obj.attachments
end
if @attachments.present?
@attachments = @attachments.sort_by{|attachment| [attachment.created_on, attachment.id]}.reverse
end
end
def scrub(node)
return unless node.name == 'img' && node['src'].present?
parse_inline_attachments(node)
end
private
def parse_inline_attachments(node)
return if @attachments.blank?
src = node['src']
if src =~ %r{\A(?<filename>[^/"]+?\.(?:bmp|gif|jpg|jpeg|jpe|png|webp))\z}i
filename = $~[:filename]
if found = find_attachment(CGI.unescape(filename))
image_url = @view.download_named_attachment_url(found, found.filename, :only_path => @only_path)
node['src'] = image_url
desc = found.description.to_s.delete('"')
if !desc.blank? && node['alt'].blank?
node['title'] = desc
node['alt'] = desc
end
node['loading'] = 'lazy'
end
end
end
def find_attachment(filename)
return unless filename.valid_encoding?
@attachments.detect do |att|
filename.casecmp?(att.filename)
end
end
end
end
end

View File

@@ -32,8 +32,9 @@ module Redmine
extend Forwardable
def_delegators :@filter, :extract_sections, :rip_offtags
def initialize(args)
@filter = Filter.new(args)
def initialize(text, options = {})
@filter = Filter.new(text)
@options = options
end
def to_html(*rules)
@@ -41,7 +42,7 @@ module Redmine
fragment = Loofah.html5_fragment(html)
scrubber = Loofah::Scrubber.new do |node|
SCRUBBERS.each do |s|
(SCRUBBERS + post_processor_scrubbers).each do |s|
result = s.scrub(node)
break result if result == Loofah::Scrubber::STOP
break if node.parent.nil?
@@ -51,6 +52,15 @@ module Redmine
fragment.scrub!(scrubber)
fragment.to_s
end
private
def post_processor_scrubbers
[
Redmine::WikiFormatting::InlineAttachmentsScrubber.new(@options),
Redmine::WikiFormatting::HiresImagesScrubber.new
]
end
end
class Filter < RedCloth3

View File

@@ -169,16 +169,16 @@ class ApplicationHelperTest < Redmine::HelperTest
def test_attached_images
to_test = {
'Inline image: !logo.gif!' =>
'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy">',
'Inline image: <img src="/attachments/download/3/logo.gif" alt="This is a logo" title="This is a logo" loading="lazy">',
'Inline image: !logo.GIF!' =>
'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy">',
'Inline image: <img src="/attachments/download/3/logo.gif" alt="This is a logo" title="This is a logo" loading="lazy">',
'Inline WebP image: !logo.webp!' =>
'Inline WebP image: <img src="/attachments/download/24/logo.webp" title="WebP image" alt="WebP image" loading="lazy">',
'Inline WebP image: <img src="/attachments/download/24/logo.webp" alt="WebP image" title="WebP image" loading="lazy">',
'No match: !ogo.gif!' => 'No match: <img src="ogo.gif" alt="">',
'No match: !ogo.GIF!' => 'No match: <img src="ogo.GIF" alt="">',
# link image
'!logo.gif!:http://foo.bar/' =>
'<a href="http://foo.bar/"><img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy"></a>',
'<a href="http://foo.bar/"><img src="/attachments/download/3/logo.gif" alt="This is a logo" title="This is a logo" loading="lazy"></a>',
}
attachments = Attachment.all
with_settings :text_formatting => 'textile' do
@@ -194,11 +194,11 @@ class ApplicationHelperTest < Redmine::HelperTest
textilizable('!logo.gif(alt text)!', attachments: attachments)
# When alt text and style are set
assert_match %r[<img src=".+?" title="alt text" alt="alt text" loading=".+?" style="width:100px;">],
assert_match %r[<img src=".+?" style="width:100px;" title="alt text" alt="alt text" loading=".+?">],
textilizable('!{width:100px}logo.gif(alt text)!', attachments: attachments)
# When alt text is not set
assert_match %r[<img src=".+?" title="This is a logo" alt="This is a logo" loading=".+?">],
assert_match %r[<img src=".+?" alt="This is a logo" title="This is a logo" loading=".+?">],
textilizable('!logo.gif!', attachments: attachments)
# When alt text is not set and the attachment has no description
@@ -272,7 +272,7 @@ class ApplicationHelperTest < Redmine::HelperTest
with_settings :text_formatting => 'textile' do
assert_equal(
%(<p><img src="/attachments/download/#{attachment.id}/image@2x.png" ) +
%(srcset="/attachments/download/#{attachment.id}/image@2x.png 2x" alt="" loading="lazy"></p>),
%(alt="" loading="lazy" srcset="/attachments/download/#{attachment.id}/image@2x.png 2x"></p>),
textilizable("!image@2x.png!", :attachments => [attachment])
)
end

View File

@@ -58,7 +58,7 @@ class Redmine::WikiFormatting::CommonMark::ApplicationHelperTest < Redmine::Help
textilizable('![alt text](logo.gif)', attachments: attachments)
# When alt text is not set
assert_match %r[<img src=".+?" title="This is a logo" alt="This is a logo" loading=".+?">],
assert_match %r[<img src=".+?" alt="This is a logo" title="This is a logo" loading=".+?">],
textilizable('![](logo.gif)', attachments: attachments)
# When alt text is not set and the attachment has no description