From ecfba2c2e9e46389e2012e9adf1ab993aaa7ea5e Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Wed, 1 Aug 2018 11:43:58 -0400 Subject: MM-11175 Add logic to server to understand markdown images with dimensions (#9159) --- utils/markdown/inlines.go | 33 +++++-- utils/markdown/links.go | 54 +++++++++++ utils/markdown/links_test.go | 223 +++++++++++++++++++++++++++++++++++++++++++ utils/markdown/markdown.go | 12 ++- 4 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 utils/markdown/links_test.go (limited to 'utils') diff --git a/utils/markdown/inlines.go b/utils/markdown/inlines.go index e6943a57d..453f4bbe5 100644 --- a/utils/markdown/inlines.go +++ b/utils/markdown/inlines.go @@ -254,7 +254,7 @@ func (p *inlineParser) parseLinkOrImageDelimiter() { } } -func (p *inlineParser) peekAtInlineLinkDestinationAndTitle(position int) (destination, title Range, end int, ok bool) { +func (p *inlineParser) peekAtInlineLinkDestinationAndTitle(position int, isImage bool) (destination, title Range, end int, ok bool) { if position >= len(p.raw) || p.raw[position] != '(' { return } @@ -273,6 +273,23 @@ func (p *inlineParser) peekAtInlineLinkDestinationAndTitle(position int) (destin } position = end + if isImage && position < len(p.raw) && isWhitespaceByte(p.raw[position]) { + dimensionsStart := nextNonWhitespace(p.raw, position) + if dimensionsStart >= len(p.raw) { + return + } + + if p.raw[dimensionsStart] == '=' { + // Read optional image dimensions even if we don't use them + _, end, ok = parseImageDimensions(p.raw, dimensionsStart) + if !ok { + return + } + + position = end + } + } + if position < len(p.raw) && isWhitespaceByte(p.raw[position]) { titleStart := nextNonWhitespace(p.raw, position) if titleStart >= len(p.raw) { @@ -281,11 +298,13 @@ func (p *inlineParser) peekAtInlineLinkDestinationAndTitle(position int) (destin return destination, Range{titleStart, titleStart}, titleStart + 1, true } - title, end, ok = parseLinkTitle(p.raw, titleStart) - if !ok { - return + if p.raw[titleStart] == '"' || p.raw[titleStart] == '\'' || p.raw[titleStart] == '(' { + title, end, ok = parseLinkTitle(p.raw, titleStart) + if !ok { + return + } + position = end } - position = end } closingPosition := nextNonWhitespace(p.raw, position) @@ -317,9 +336,11 @@ func (p *inlineParser) lookForLinkOrImage() { break } + isImage := d.Type == imageOpeningDelimiter + var inline Inline - if destination, title, next, ok := p.peekAtInlineLinkDestinationAndTitle(p.position + 1); ok { + if destination, title, next, ok := p.peekAtInlineLinkDestinationAndTitle(p.position+1, isImage); ok { destinationMarkdownPosition := relativeToAbsolutePosition(p.ranges, destination.Position) linkOrImage := InlineLinkOrImage{ Children: append([]Inline(nil), p.inlines[d.TextNode+1:]...), diff --git a/utils/markdown/links.go b/utils/markdown/links.go index 419797cb9..9f3128c4f 100644 --- a/utils/markdown/links.go +++ b/utils/markdown/links.go @@ -128,3 +128,57 @@ func parseLinkLabel(markdown string, position int) (raw Range, next int, ok bool return } + +// As a non-standard feature, we allow image links to specify dimensions of the image by adding "=WIDTHxHEIGHT" +// after the image destination but before the image title like ![alt](http://example.com/image.png =100x200 "title"). +// Both width and height are optional, but at least one of them must be specified. +func parseImageDimensions(markdown string, position int) (raw Range, next int, ok bool) { + if position >= len(markdown) { + return + } + + originalPosition := position + + // Read = + position += 1 + if position >= len(markdown) { + return + } + + // Read width + hasWidth := false + for isNumericByte(markdown[position]) { + hasWidth = true + position += 1 + } + + // Look for early end of dimensions + if isWhitespaceByte(markdown[position]) || markdown[position] == ')' { + return Range{originalPosition, position - 1}, position, true + } + + // Read the x + if markdown[position] != 'x' && markdown[position] != 'X' { + return + } + position += 1 + + // Read height + hasHeight := false + for isNumericByte(markdown[position]) { + hasHeight = true + position += 1 + } + + // Make sure the there's no trailing characters + if !isWhitespaceByte(markdown[position]) && markdown[position] != ')' { + return + } + + if !hasWidth && !hasHeight { + // At least one of width or height is required + return + } + + return Range{originalPosition, position - 1}, position, true +} diff --git a/utils/markdown/links_test.go b/utils/markdown/links_test.go new file mode 100644 index 000000000..15012e26b --- /dev/null +++ b/utils/markdown/links_test.go @@ -0,0 +1,223 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package markdown + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseImageDimensions(t *testing.T) { + for name, tc := range map[string]struct { + Input string + Position int + ExpectedRange Range + ExpectedNext int + ExpectedOk bool + }{ + "no dimensions, no title": { + Input: `![alt](https://example.com)`, + Position: 26, + ExpectedRange: Range{0, 0}, + ExpectedNext: 0, + ExpectedOk: false, + }, + "no dimensions, title": { + Input: `![alt](https://example.com "title")`, + Position: 27, + ExpectedRange: Range{0, 0}, + ExpectedNext: 0, + ExpectedOk: false, + }, + "only width, no title": { + Input: `![alt](https://example.com =100)`, + Position: 27, + ExpectedRange: Range{27, 30}, + ExpectedNext: 31, + ExpectedOk: true, + }, + "only width, title": { + Input: `![alt](https://example.com =100 "title")`, + Position: 27, + ExpectedRange: Range{27, 30}, + ExpectedNext: 31, + ExpectedOk: true, + }, + "only height, no title": { + Input: `![alt](https://example.com =x100)`, + Position: 27, + ExpectedRange: Range{27, 31}, + ExpectedNext: 32, + ExpectedOk: true, + }, + "only height, title": { + Input: `![alt](https://example.com =x100 "title")`, + Position: 27, + ExpectedRange: Range{27, 31}, + ExpectedNext: 32, + ExpectedOk: true, + }, + "dimensions, no title": { + Input: `![alt](https://example.com =100x200)`, + Position: 27, + ExpectedRange: Range{27, 34}, + ExpectedNext: 35, + ExpectedOk: true, + }, + "dimensions, title": { + Input: `![alt](https://example.com =100x200 "title")`, + Position: 27, + ExpectedRange: Range{27, 34}, + ExpectedNext: 35, + ExpectedOk: true, + }, + "no dimensions, no title, trailing whitespace": { + Input: `![alt](https://example.com )`, + Position: 27, + ExpectedRange: Range{0, 0}, + ExpectedNext: 0, + ExpectedOk: false, + }, + "only width, no title, trailing whitespace": { + Input: `![alt](https://example.com =100 )`, + Position: 28, + ExpectedRange: Range{28, 31}, + ExpectedNext: 32, + ExpectedOk: true, + }, + "only height, no title, trailing whitespace": { + Input: `![alt](https://example.com =x100 )`, + Position: 29, + ExpectedRange: Range{29, 33}, + ExpectedNext: 34, + ExpectedOk: true, + }, + "dimensions, no title, trailing whitespace": { + Input: `![alt](https://example.com =100x200 )`, + Position: 30, + ExpectedRange: Range{30, 37}, + ExpectedNext: 38, + ExpectedOk: true, + }, + "no width or height": { + Input: `![alt](https://example.com =x)`, + Position: 27, + ExpectedRange: Range{0, 0}, + ExpectedNext: 0, + ExpectedOk: false, + }, + "garbage 1": { + Input: `![alt](https://example.com =aaa)`, + Position: 27, + ExpectedRange: Range{0, 0}, + ExpectedNext: 0, + ExpectedOk: false, + }, + "garbage 2": { + Input: `![alt](https://example.com ====)`, + Position: 27, + ExpectedRange: Range{0, 0}, + ExpectedNext: 0, + ExpectedOk: false, + }, + "garbage 3": { + Input: `![alt](https://example.com =100xx200)`, + Position: 27, + ExpectedRange: Range{0, 0}, + ExpectedNext: 0, + ExpectedOk: false, + }, + "garbage 4": { + Input: `![alt](https://example.com =100x200x300x400)`, + Position: 27, + ExpectedRange: Range{0, 0}, + ExpectedNext: 0, + ExpectedOk: false, + }, + } { + t.Run(name, func(t *testing.T) { + raw, next, ok := parseImageDimensions(tc.Input, tc.Position) + assert.Equal(t, tc.ExpectedOk, ok) + assert.Equal(t, tc.ExpectedNext, next) + assert.Equal(t, tc.ExpectedRange, raw) + }) + } +} + +func TestImageLinksWithDimensions(t *testing.T) { + for name, tc := range map[string]struct { + Markdown string + ExpectedHTML string + }{ + "regular link": { + Markdown: `[link](https://example.com)`, + ExpectedHTML: `

link

`, + }, + "image link": { + Markdown: `![image](https://example.com/image.png)`, + ExpectedHTML: `

image

`, + }, + "image link with title": { + Markdown: `![image](https://example.com/image.png "title")`, + ExpectedHTML: `

image

`, + }, + "image link with bracketed title": { + Markdown: `![image](https://example.com/image.png (title))`, + ExpectedHTML: `

image

`, + }, + "image link with width": { + Markdown: `![image](https://example.com/image.png =500)`, + ExpectedHTML: `

image

`, + }, + "image link with width and title": { + Markdown: `![image](https://example.com/image.png =500 "title")`, + ExpectedHTML: `

image

`, + }, + "image link with width and bracketed title": { + Markdown: `![image](https://example.com/image.png =500 (title))`, + ExpectedHTML: `

image

`, + }, + "image link with height": { + Markdown: `![image](https://example.com/image.png =x500)`, + ExpectedHTML: `

image

`, + }, + "image link with height and title": { + Markdown: `![image](https://example.com/image.png =x500 "title")`, + ExpectedHTML: `

image

`, + }, + "image link with height and bracketed title": { + Markdown: `![image](https://example.com/image.png =x500 (title))`, + ExpectedHTML: `

image

`, + }, + "image link with dimensions": { + Markdown: `![image](https://example.com/image.png =500x400)`, + ExpectedHTML: `

image

`, + }, + "image link with dimensions and title": { + Markdown: `![image](https://example.com/image.png =500x400 "title")`, + ExpectedHTML: `

image

`, + }, + "image link with dimensions and bracketed title": { + Markdown: `![image](https://example.com/image.png =500x400 (title))`, + ExpectedHTML: `

image

`, + }, + "no image link 1": { + Markdown: `![image]()`, + ExpectedHTML: `

image

`, + }, + "no image link 2": { + Markdown: `![image]( )`, + ExpectedHTML: `

image

`, + }, + "no image link with dimensions": { + Markdown: `![image]( =500x400)`, + ExpectedHTML: `

image

`, + }, + } { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.ExpectedHTML, RenderHTML(tc.Markdown)) + }) + } +} diff --git a/utils/markdown/markdown.go b/utils/markdown/markdown.go index e0788d906..57b10f3fb 100644 --- a/utils/markdown/markdown.go +++ b/utils/markdown/markdown.go @@ -32,8 +32,16 @@ func isWhitespaceByte(c byte) bool { return isWhitespace(rune(c)) } +func isNumeric(c rune) bool { + return c >= '0' && c <= '9' +} + +func isNumericByte(c byte) bool { + return isNumeric(rune(c)) +} + func isHex(c rune) bool { - return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') + return isNumeric(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') } func isHexByte(c byte) bool { @@ -41,7 +49,7 @@ func isHexByte(c byte) bool { } func isAlphanumeric(c rune) bool { - return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + return isNumeric(c) || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') } func isAlphanumericByte(c byte) bool { -- cgit v1.2.3-1-g7c22