Patch #43745 » 0001-Moves-parse-hires-images-and-parse-inline-attachment.patch
| 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('', 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('', attachments: attachments)
|
| 63 | 63 | |
| 64 | 64 |
# When alt text is not set and the attachment has no description |