From bc29dbbbe761d0c12ff7ec1a14fa2315d09194f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20B=C4=82LTEANU?= Date: Thu, 29 Jan 2026 14:22:40 +0700 Subject: [PATCH] Moves parse hires images and parse inline attachments to ImagesScrubber. --- app/helpers/application_helper.rb | 56 ++----------- lib/redmine/wiki_formatting.rb | 7 +- .../wiki_formatting/common_mark/formatter.rb | 14 +++- .../wiki_formatting/hires_images_scrubber.rb | 41 ++++++++++ lib/redmine/wiki_formatting/html_sanitizer.rb | 5 +- .../inline_attachments_scrubber.rb | 79 +++++++++++++++++++ .../wiki_formatting/textile/formatter.rb | 16 +++- test/helpers/application_helper_test.rb | 14 ++-- .../common_mark/application_helper_test.rb | 2 +- 9 files changed, 166 insertions(+), 68 deletions(-) create mode 100644 lib/redmine/wiki_formatting/hires_images_scrubber.rb create mode 100644 lib/redmine/wiki_formatting/inline_attachments_scrubber.rb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index aabb78410..3a5b961b5 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -913,7 +913,9 @@ 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 = options.delete(:only_path) == false ? false : true + options[:only_path] = only_path + @only_path = only_path text = text.dup macros = catch_macros(text) @@ -922,7 +924,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 = [] @@ -931,7 +933,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 @@ -976,54 +978,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: diff --git a/lib/redmine/wiki_formatting.rb b/lib/redmine/wiki_formatting.rb index ce6097646..3d61df87d 100644 --- a/lib/redmine/wiki_formatting.rb +++ b/lib/redmine/wiki_formatting.rb @@ -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) diff --git a/lib/redmine/wiki_formatting/common_mark/formatter.rb b/lib/redmine/wiki_formatting/common_mark/formatter.rb index 9ef52692e..f9e22467d 100644 --- a/lib/redmine/wiki_formatting/common_mark/formatter.rb +++ b/lib/redmine/wiki_formatting/common_mark/formatter.rb @@ -65,19 +65,29 @@ module Redmine class Formatter include Redmine::WikiFormatting::SectionHelper - def initialize(text) + def initialize(text, options = {}) @text = text + @options = options end def to_html(*args) html = MarkdownFilter.new(@text, PIPELINE_CONFIG).call fragment = Redmine::WikiFormatting::HtmlParser.parse(html) SANITIZER.call(fragment) - SCRUBBERS.each do |scrubber| + (SCRUBBERS + post_processor_scrubbers).each do |scrubber| fragment.scrub!(scrubber) end fragment.to_s end + + private + + def post_processor_scrubbers + [ + Redmine::WikiFormatting::InlineAttachmentsScrubber.new(@options), + Redmine::WikiFormatting::HiresImagesScrubber.new + ] + end end end end diff --git a/lib/redmine/wiki_formatting/hires_images_scrubber.rb b/lib/redmine/wiki_formatting/hires_images_scrubber.rb new file mode 100644 index 000000000..b483497d4 --- /dev/null +++ b/lib/redmine/wiki_formatting/hires_images_scrubber.rb @@ -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 = /@(?\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 diff --git a/lib/redmine/wiki_formatting/html_sanitizer.rb b/lib/redmine/wiki_formatting/html_sanitizer.rb index d818687b0..b0f608c56 100644 --- a/lib/redmine/wiki_formatting/html_sanitizer.rb +++ b/lib/redmine/wiki_formatting/html_sanitizer.rb @@ -22,7 +22,10 @@ module Redmine # Combination of SanitizationFilter and ExternalLinksScrubber class HtmlSanitizer SANITIZER = Redmine::WikiFormatting::CommonMark::SanitizationFilter.new - SCRUBBERS = [Redmine::WikiFormatting::CommonMark::ExternalLinksScrubber.new] + SCRUBBERS = [ + Redmine::WikiFormatting::CommonMark::ExternalLinksScrubber.new, + Redmine::WikiFormatting::HiresImagesScrubber.new + ] def self.call(html) fragment = HtmlParser.parse(html) diff --git a/lib/redmine/wiki_formatting/inline_attachments_scrubber.rb b/lib/redmine/wiki_formatting/inline_attachments_scrubber.rb new file mode 100644 index 000000000..2da380df7 --- /dev/null +++ b/lib/redmine/wiki_formatting/inline_attachments_scrubber.rb @@ -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(?[^/"]+?\.(?: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 diff --git a/lib/redmine/wiki_formatting/textile/formatter.rb b/lib/redmine/wiki_formatting/textile/formatter.rb index 57d8dbab4..0e7807987 100644 --- a/lib/redmine/wiki_formatting/textile/formatter.rb +++ b/lib/redmine/wiki_formatting/textile/formatter.rb @@ -32,18 +32,28 @@ 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) html = @filter.to_html(rules) fragment = Loofah.html5_fragment(html) - SCRUBBERS.each do |scrubber| + (SCRUBBERS + post_processor_scrubbers).each do |scrubber| fragment.scrub!(scrubber) end fragment.to_s end + + private + + def post_processor_scrubbers + [ + Redmine::WikiFormatting::InlineAttachmentsScrubber.new(@options), + Redmine::WikiFormatting::HiresImagesScrubber.new + ] + end end class Filter < RedCloth3 diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb index 87b57c7f2..9674a1407 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -169,16 +169,16 @@ class ApplicationHelperTest < Redmine::HelperTest def test_attached_images to_test = { 'Inline image: !logo.gif!' => - 'Inline image: This is a logo', + 'Inline image: This is a logo', 'Inline image: !logo.GIF!' => - 'Inline image: This is a logo', + 'Inline image: This is a logo', 'Inline WebP image: !logo.webp!' => - 'Inline WebP image: WebP image', + 'Inline WebP image: WebP image', 'No match: !ogo.gif!' => 'No match: ', 'No match: !ogo.GIF!' => 'No match: ', # link image '!logo.gif!:http://foo.bar/' => - 'This is a logo', + 'This is a logo', } 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[alt text], + assert_match %r[alt text], textilizable('!{width:100px}logo.gif(alt text)!', attachments: attachments) # When alt text is not set - assert_match %r[This is a logo], + assert_match %r[This is a logo], 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( %(

), + %(alt="" loading="lazy" srcset="/attachments/download/#{attachment.id}/image@2x.png 2x">

), textilizable("!image@2x.png!", :attachments => [attachment]) ) end diff --git a/test/unit/lib/redmine/wiki_formatting/common_mark/application_helper_test.rb b/test/unit/lib/redmine/wiki_formatting/common_mark/application_helper_test.rb index 4ecc3e62a..21aaf79c4 100644 --- a/test/unit/lib/redmine/wiki_formatting/common_mark/application_helper_test.rb +++ b/test/unit/lib/redmine/wiki_formatting/common_mark/application_helper_test.rb @@ -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[This is a logo], + assert_match %r[This is a logo], textilizable('![](logo.gif)', attachments: attachments) # When alt text is not set and the attachment has no description -- 2.50.1 (Apple Git-155)