Project

General

Profile

Patch #43745 » 0001-Moves-parse-hires-images-and-parse-inline-attachment.patch

Marius BĂLTEANU, 2026-02-05 03:04

View differences:

app/helpers/application_helper.rb
913 913
    return '' if text.blank?
914 914

  
915 915
    project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
916
    @only_path = only_path = options.delete(:only_path) == false ? false : true
916
    only_path = options.delete(:only_path) == false ? false : true
917
    options[:only_path] = only_path
918
    @only_path = only_path
917 919

  
918 920
    text = text.dup
919 921
    macros = catch_macros(text)
......
922 924
      text = h(text)
923 925
    else
924 926
      formatting = Setting.text_formatting
925
      text = Redmine::WikiFormatting.to_html(formatting, text, :object => obj, :attribute => attr)
927
      text = Redmine::WikiFormatting.to_html(formatting, text, options.merge(:object => obj, :attribute => attr, :view => self))
926 928
    end
927 929

  
928 930
    @parsed_headings = []
......
931 933

  
932 934
    parse_sections(text, project, obj, attr, only_path, options)
933 935
    text = parse_non_pre_blocks(text, obj, macros, options) do |txt|
934
      [:parse_inline_attachments, :parse_hires_images, :parse_wiki_links, :parse_redmine_links].each do |method_name|
936
      [:parse_wiki_links, :parse_redmine_links].each do |method_name|
935 937
        send method_name, txt, project, obj, attr, only_path, options
936 938
      end
937 939
    end
......
976 978
    parsed
977 979
  end
978 980

  
979
  # add srcset attribute to img tags if filename includes @2x, @3x, etc.
980
  # to support hires displays
981
  def parse_hires_images(text, project, obj, attr, only_path, options)
982
    text.gsub!(/src="([^"]+@(\dx)\.(bmp|gif|jpg|jpe|jpeg|png))"/i) do |m|
983
      filename, dpr = $1, $2
984
      m + " srcset=\"#{filename} #{dpr}\""
985
    end
986
  end
987

  
988
  def parse_inline_attachments(text, project, obj, attr, only_path, options)
989
    return if options[:inline_attachments] == false
990

  
991
    # when using an image link, try to use an attachment, if possible
992
    attachments = options[:attachments] || []
993
    if obj.is_a?(Journal)
994
      attachments += obj.journalized.attachments if obj.journalized.respond_to?(:attachments)
995
    else
996
      attachments += obj.attachments if obj.respond_to?(:attachments)
997
    end
998
    if attachments.present?
999
      title_and_alt_re = /\s+(title|alt)="([^"]*)"/i
1000

  
1001
      text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png|webp))"([^>]*)/i) do |m|
1002
        filename, ext, other_attrs = $1, $2, $3
1003

  
1004
        # search for the picture in attachments
1005
        if found = Attachment.latest_attach(attachments, CGI.unescape(filename))
1006
          image_url = download_named_attachment_url(found, found.filename, :only_path => only_path)
1007
          desc = found.description.to_s.delete('"')
1008

  
1009
          # remove title and alt attributes after extracting them
1010
          title_and_alt = other_attrs.scan(title_and_alt_re).to_h
1011
          other_attrs.gsub!(title_and_alt_re, '')
1012

  
1013
          title_and_alt_attrs = if !desc.blank? && title_and_alt['alt'].blank?
1014
                                  " title=\"#{desc}\" alt=\"#{desc}\""
1015
                                else
1016
                                  # restore original title and alt attributes
1017
                                  " #{title_and_alt.map { |k, v| %[#{k}="#{v}"] }.join(' ')}"
1018
                                end
1019
          "src=\"#{image_url}\"#{title_and_alt_attrs} loading=\"lazy\"#{other_attrs}"
1020
        else
1021
          m
1022
        end
1023
      end
1024
    end
1025
  end
1026

  
1027 981
  # Wiki links
1028 982
  #
1029 983
  # Examples:
lib/redmine/wiki_formatting.rb
93 93
            # Text retrieved from the cache store may be frozen
94 94
            # We need to dup it so we can do in-place substitutions with gsub!
95 95
            cache_store.fetch cache_key do
96
              formatter_for(format).new(text).to_html
96
              formatter_for(format).new(text, options).to_html
97 97
            end.dup
98 98
          else
99
            formatter_for(format).new(text).to_html
99
            formatter_for(format).new(text, options).to_html
100 100
          end
101 101
        text
102 102
      end
......
127 127
        include ActionView::Helpers::UrlHelper
128 128
        include Redmine::WikiFormatting::LinksHelper
129 129

  
130
        def initialize(text)
130
        def initialize(text, options = {})
131 131
          @text = text
132
          @options = options
132 133
        end
133 134

  
134 135
        def to_html(*args)
lib/redmine/wiki_formatting/common_mark/formatter.rb
65 65
      class Formatter
66 66
        include Redmine::WikiFormatting::SectionHelper
67 67

  
68
        def initialize(text)
68
        def initialize(text, options = {})
69 69
          @text = text
70
          @options = options
70 71
        end
71 72

  
72 73
        def to_html(*args)
73 74
          html = MarkdownFilter.new(@text, PIPELINE_CONFIG).call
74 75
          fragment = Redmine::WikiFormatting::HtmlParser.parse(html)
75 76
          SANITIZER.call(fragment)
76
          SCRUBBERS.each do |scrubber|
77
          (SCRUBBERS + post_processor_scrubbers).each do |scrubber|
77 78
            fragment.scrub!(scrubber)
78 79
          end
79 80
          fragment.to_s
80 81
        end
82

  
83
        private
84

  
85
        def post_processor_scrubbers
86
          [
87
            Redmine::WikiFormatting::InlineAttachmentsScrubber.new(@options),
88
            Redmine::WikiFormatting::HiresImagesScrubber.new
89
          ]
90
        end
81 91
      end
82 92
    end
83 93
  end
lib/redmine/wiki_formatting/hires_images_scrubber.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
module Redmine
21
  module WikiFormatting
22
    class HiresImagesScrubber < Loofah::Scrubber
23
      HIRES_FILENAME_REGEX = /@(?<dpr>\dx)\.(?:bmp|gif|jpg|jpe|jpeg|png)\z/i
24

  
25
      def scrub(node)
26
        return unless node.name == 'img' && node['src'].present?
27

  
28
        src = node['src']
29

  
30
        return unless src.include?('@')
31

  
32
        match = src.match(HIRES_FILENAME_REGEX)
33

  
34
        return unless match
35

  
36
        # Set the srcset attribute.
37
        node['srcset'] = "#{src} #{match[:dpr]}"
38
      end
39
    end
40
  end
41
end
lib/redmine/wiki_formatting/html_sanitizer.rb
22 22
    # Combination of SanitizationFilter and ExternalLinksScrubber
23 23
    class HtmlSanitizer
24 24
      SANITIZER = Redmine::WikiFormatting::CommonMark::SanitizationFilter.new
25
      SCRUBBERS = [Redmine::WikiFormatting::CommonMark::ExternalLinksScrubber.new]
25
      SCRUBBERS = [
26
        Redmine::WikiFormatting::CommonMark::ExternalLinksScrubber.new,
27
        Redmine::WikiFormatting::HiresImagesScrubber.new
28
      ]
26 29

  
27 30
      def self.call(html)
28 31
        fragment = HtmlParser.parse(html)
lib/redmine/wiki_formatting/inline_attachments_scrubber.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
module Redmine
21
  module WikiFormatting
22
    class InlineAttachmentsScrubber < Loofah::Scrubber
23
      def initialize(options = {})
24
        super()
25
        @options = options
26
        @obj = options[:object]
27
        @view = options[:view]
28
        @only_path = options[:only_path]
29
        @attachments = options[:attachments] || []
30
        if @obj.is_a?(Journal)
31
          @attachments += @obj.journalized.attachments if @obj.journalized.respond_to?(:attachments)
32
        elsif @obj.respond_to?(:attachments)
33
          @attachments += @obj.attachments
34
        end
35

  
36
        if @attachments.present?
37
          @attachments = @attachments.sort_by{|attachment| [attachment.created_on, attachment.id]}.reverse
38
        end
39
      end
40

  
41
      def scrub(node)
42
        return unless node.name == 'img' && node['src'].present?
43

  
44
        parse_inline_attachments(node)
45
      end
46

  
47
      private
48

  
49
      def parse_inline_attachments(node)
50
        return if @attachments.blank?
51

  
52
        src = node['src']
53

  
54
        if src =~ %r{\A(?<filename>[^/"]+?\.(?:bmp|gif|jpg|jpeg|jpe|png|webp))\z}i
55
          filename = $~[:filename]
56
          if found = find_attachment(CGI.unescape(filename))
57
            image_url = @view.download_named_attachment_url(found, found.filename, :only_path => @only_path)
58
            node['src'] = image_url
59

  
60
            desc = found.description.to_s.delete('"')
61
            if !desc.blank? && node['alt'].blank?
62
              node['title'] = desc
63
              node['alt'] = desc
64
            end
65
            node['loading'] = 'lazy'
66
          end
67
        end
68
      end
69

  
70
      def find_attachment(filename)
71
        return unless filename.valid_encoding?
72

  
73
        @attachments.detect do |att|
74
          filename.casecmp?(att.filename)
75
        end
76
      end
77
    end
78
  end
79
end
lib/redmine/wiki_formatting/textile/formatter.rb
32 32
        extend Forwardable
33 33
        def_delegators :@filter, :extract_sections, :rip_offtags
34 34

  
35
        def initialize(args)
36
          @filter = Filter.new(args)
35
        def initialize(text, options = {})
36
          @filter = Filter.new(text)
37
          @options = options
37 38
        end
38 39

  
39 40
        def to_html(*rules)
40 41
          html = @filter.to_html(rules)
41 42
          fragment = Loofah.html5_fragment(html)
42
          SCRUBBERS.each do |scrubber|
43
          (SCRUBBERS + post_processor_scrubbers).each do |scrubber|
43 44
            fragment.scrub!(scrubber)
44 45
          end
45 46
          fragment.to_s
46 47
        end
48

  
49
        private
50

  
51
        def post_processor_scrubbers
52
          [
53
            Redmine::WikiFormatting::InlineAttachmentsScrubber.new(@options),
54
            Redmine::WikiFormatting::HiresImagesScrubber.new
55
          ]
56
        end
47 57
      end
48 58

  
49 59
      class Filter < RedCloth3
test/helpers/application_helper_test.rb
169 169
  def test_attached_images
170 170
    to_test = {
171 171
      'Inline image: !logo.gif!' =>
172
         'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy">',
172
         'Inline image: <img src="/attachments/download/3/logo.gif" alt="This is a logo" title="This is a logo" loading="lazy">',
173 173
      'Inline image: !logo.GIF!' =>
174
         'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy">',
174
         'Inline image: <img src="/attachments/download/3/logo.gif" alt="This is a logo" title="This is a logo" loading="lazy">',
175 175
      'Inline WebP image: !logo.webp!' =>
176
         'Inline WebP image: <img src="/attachments/download/24/logo.webp" title="WebP image" alt="WebP image" loading="lazy">',
176
         'Inline WebP image: <img src="/attachments/download/24/logo.webp" alt="WebP image" title="WebP image" loading="lazy">',
177 177
      'No match: !ogo.gif!' => 'No match: <img src="ogo.gif" alt="">',
178 178
      'No match: !ogo.GIF!' => 'No match: <img src="ogo.GIF" alt="">',
179 179
      # link image
180 180
      '!logo.gif!:http://foo.bar/' =>
181
         '<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>',
181
         '<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>',
182 182
    }
183 183
    attachments = Attachment.all
184 184
    with_settings :text_formatting => 'textile' do
......
194 194
        textilizable('!logo.gif(alt text)!', attachments: attachments)
195 195

  
196 196
      # When alt text and style are set
197
      assert_match %r[<img src=".+?" title="alt text" alt="alt text" loading=".+?" style="width:100px;">],
197
      assert_match %r[<img src=".+?" style="width:100px;" title="alt text" alt="alt text" loading=".+?">],
198 198
        textilizable('!{width:100px}logo.gif(alt text)!', attachments: attachments)
199 199

  
200 200
      # When alt text is not set
201
      assert_match %r[<img src=".+?" title="This is a logo" alt="This is a logo" loading=".+?">],
201
      assert_match %r[<img src=".+?" alt="This is a logo" title="This is a logo" loading=".+?">],
202 202
        textilizable('!logo.gif!', attachments: attachments)
203 203

  
204 204
      # When alt text is not set and the attachment has no description
......
272 272
    with_settings :text_formatting => 'textile' do
273 273
      assert_equal(
274 274
        %(<p><img src="/attachments/download/#{attachment.id}/image@2x.png" ) +
275
          %(srcset="/attachments/download/#{attachment.id}/image@2x.png 2x" alt="" loading="lazy"></p>),
275
          %(alt="" loading="lazy" srcset="/attachments/download/#{attachment.id}/image@2x.png 2x"></p>),
276 276
        textilizable("!image@2x.png!", :attachments => [attachment])
277 277
      )
278 278
    end
test/unit/lib/redmine/wiki_formatting/common_mark/application_helper_test.rb
58 58
          textilizable('![alt text](logo.gif)', attachments: attachments)
59 59

  
60 60
        # When alt text is not set
61
        assert_match %r[<img src=".+?" title="This is a logo" alt="This is a logo" loading=".+?">],
61
        assert_match %r[<img src=".+?" alt="This is a logo" title="This is a logo" loading=".+?">],
62 62
          textilizable('![](logo.gif)', attachments: attachments)
63 63

  
64 64
        # When alt text is not set and the attachment has no description
    (1-1/1)