From 8d3425dab4aa1d3c74b972c252e5bfec53f376b2 Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Thu, 3 May 2018 02:56:01 +0900 Subject: [PATCH 1/9] Implement CSS filter for image and advanced backgrounds --- src/markdown/background_image.js | 19 ++++++-- src/markdown/parse_image.js | 74 ++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/markdown/background_image.js b/src/markdown/background_image.js index 42079d43..c6f7acdc 100644 --- a/src/markdown/background_image.js +++ b/src/markdown/background_image.js @@ -92,7 +92,13 @@ function backgroundImage(md) { tb.children.forEach(t => { if (t.type !== 'image') return - const { background, backgroundSize, size, url } = t.meta.marpitImage + const { + background, + backgroundSize, + filter, + size, + url, + } = t.meta.marpitImage if (background && !url.match(/^\s*$/)) { current.images = [ @@ -100,6 +106,7 @@ function backgroundImage(md) { { url, size: size || backgroundSize || undefined, + filter, }, ] } @@ -149,9 +156,13 @@ function backgroundImage(md) { ...imgArr, ...wrapTokens('marpit_advanced_background_image', { tag: 'figure', - style: `background-image:url("${img.url}");${ - img.size ? `background-size:${img.size};` : '' - }`, + style: [ + `background-image:url("${img.url}");`, + img.size && `background-size:${img.size};`, + img.filter && `filter:${img.filter};`, + ] + .filter(s => s) + .join(''), }), ], [] diff --git a/src/markdown/parse_image.js b/src/markdown/parse_image.js index 255dc7f7..2effab9c 100644 --- a/src/markdown/parse_image.js +++ b/src/markdown/parse_image.js @@ -1,4 +1,54 @@ /** @module */ +const optionMatchers = new Map() + +// The scale percentage for resize +// TODO: Implement cross-browser image zoom without affecting DOM tree +// (Pre-released Marp uses `zoom` but it has not supported in Firefox) +optionMatchers.set(/^(\d*\.)?\d+%$/, matches => ({ size: matches[0] })) + +// CSS filters +optionMatchers.set(/^blur(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['blur', matches[1] || '10px']], +})) +optionMatchers.set(/^brightness(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['brightness', matches[1] || '1.5']], +})) +optionMatchers.set(/^contrast(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['contrast', matches[1] || '2']], +})) +optionMatchers.set( + /^drop-shadow(?::(.+?),(.+?)(?:,(.+?))?(?:,(.+?))?)?$/, + (matches, meta) => ({ + filters: [ + ...meta.filters, + [ + 'drop-shadow', + matches + .slice(1) + .filter(v => v) + .join(' ') || '0 5px 10px rgba(0,0,0,.4)', + ], + ], + }) +) +optionMatchers.set(/^grayscale(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['grayscale', matches[1] || '1']], +})) +optionMatchers.set(/^hue-rotate(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['hue-rotate', matches[1] || '180deg']], +})) +optionMatchers.set(/^invert(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['invert', matches[1] || '1']], +})) +optionMatchers.set(/^opacity(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['opacity', matches[1] || '.5']], +})) +optionMatchers.set(/^saturate(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['saturate', matches[1] || '2']], +})) +optionMatchers.set(/^sepia(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['sepia', matches[1] || '1']], +})) /** * Marpit parse image plugin. @@ -24,10 +74,28 @@ function parseImage(md) { } options.forEach(opt => { - // TODO: Implement cross-browser image zoom without affecting DOM tree - // (Pre-released Marp uses `zoom` but it has not supported in Firefox) - if (opt.match(/^(\d*\.)?\d+%$/)) token.meta.marpitImage.size = opt + optionMatchers.forEach((mergeFunc, regexp) => { + const matched = opt.match(regexp) + + if (matched) + token.meta.marpitImage = { + ...token.meta.marpitImage, + ...mergeFunc(matched, { + filters: [], + ...token.meta.marpitImage, + }), + } + }) }) + + // Build and apply filter style + if (token.meta.marpitImage.filters) { + token.meta.marpitImage.filter = token.meta.marpitImage.filters + .reduce((arr, fltrs) => [...arr, `${fltrs[0]}(${fltrs[1]})`], []) + .join(' ') + + token.attrJoin('style', `filter:${token.meta.marpitImage.filter};`) + } } }) }) From 4dde405def7d1559aae9af365a27f9efd32a8188 Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Thu, 3 May 2018 09:19:47 +0900 Subject: [PATCH 2/9] Add filters option to Marpit instance options --- src/markdown/parse_image.js | 108 +++++++++++++++++++----------------- src/marpit.js | 7 ++- 2 files changed, 62 insertions(+), 53 deletions(-) diff --git a/src/markdown/parse_image.js b/src/markdown/parse_image.js index 2effab9c..974d1f78 100644 --- a/src/markdown/parse_image.js +++ b/src/markdown/parse_image.js @@ -1,54 +1,4 @@ /** @module */ -const optionMatchers = new Map() - -// The scale percentage for resize -// TODO: Implement cross-browser image zoom without affecting DOM tree -// (Pre-released Marp uses `zoom` but it has not supported in Firefox) -optionMatchers.set(/^(\d*\.)?\d+%$/, matches => ({ size: matches[0] })) - -// CSS filters -optionMatchers.set(/^blur(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['blur', matches[1] || '10px']], -})) -optionMatchers.set(/^brightness(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['brightness', matches[1] || '1.5']], -})) -optionMatchers.set(/^contrast(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['contrast', matches[1] || '2']], -})) -optionMatchers.set( - /^drop-shadow(?::(.+?),(.+?)(?:,(.+?))?(?:,(.+?))?)?$/, - (matches, meta) => ({ - filters: [ - ...meta.filters, - [ - 'drop-shadow', - matches - .slice(1) - .filter(v => v) - .join(' ') || '0 5px 10px rgba(0,0,0,.4)', - ], - ], - }) -) -optionMatchers.set(/^grayscale(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['grayscale', matches[1] || '1']], -})) -optionMatchers.set(/^hue-rotate(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['hue-rotate', matches[1] || '180deg']], -})) -optionMatchers.set(/^invert(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['invert', matches[1] || '1']], -})) -optionMatchers.set(/^opacity(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['opacity', matches[1] || '.5']], -})) -optionMatchers.set(/^saturate(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['saturate', matches[1] || '2']], -})) -optionMatchers.set(/^sepia(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['sepia', matches[1] || '1']], -})) /** * Marpit parse image plugin. @@ -59,8 +9,64 @@ optionMatchers.set(/^sepia(?::(.+))?$/, (matches, meta) => ({ * * @alias module:markdown/parse_image * @param {MarkdownIt} md markdown-it instance. + * @param {Object} [opts] + * @param {boolean} [opts.filters=true] Switch feature to support CSS filters. */ -function parseImage(md) { +function parseImage(md, opts = {}) { + const pluginOptions = { filters: true, ...opts } + const optionMatchers = new Map() + + // The scale percentage for resize + // TODO: Implement cross-browser image zoom without affecting DOM tree + // (Pre-released Marp uses `zoom` but it has not supported in Firefox) + optionMatchers.set(/^(\d*\.)?\d+%$/, matches => ({ size: matches[0] })) + + if (pluginOptions.filters) { + // CSS filters + optionMatchers.set(/^blur(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['blur', matches[1] || '10px']], + })) + optionMatchers.set(/^brightness(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['brightness', matches[1] || '1.5']], + })) + optionMatchers.set(/^contrast(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['contrast', matches[1] || '2']], + })) + optionMatchers.set( + /^drop-shadow(?::(.+?),(.+?)(?:,(.+?))?(?:,(.+?))?)?$/, + (matches, meta) => ({ + filters: [ + ...meta.filters, + [ + 'drop-shadow', + matches + .slice(1) + .filter(v => v) + .join(' ') || '0 5px 10px rgba(0,0,0,.4)', + ], + ], + }) + ) + optionMatchers.set(/^grayscale(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['grayscale', matches[1] || '1']], + })) + optionMatchers.set(/^hue-rotate(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['hue-rotate', matches[1] || '180deg']], + })) + optionMatchers.set(/^invert(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['invert', matches[1] || '1']], + })) + optionMatchers.set(/^opacity(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['opacity', matches[1] || '.5']], + })) + optionMatchers.set(/^saturate(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['saturate', matches[1] || '2']], + })) + optionMatchers.set(/^sepia(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['sepia', matches[1] || '1']], + })) + } + md.inline.ruler2.push('marpit_parse_image', ({ tokens }) => { tokens.forEach(token => { if (token.type === 'image') { diff --git a/src/marpit.js b/src/marpit.js index c74ec869..8288b0e0 100644 --- a/src/marpit.js +++ b/src/marpit.js @@ -16,6 +16,7 @@ import marpItSweep from './markdown/sweep' const defaultOptions = { backgroundSyntax: true, container: marpitContainer, + filters: true, markdown: 'commonmark', printable: true, slideContainer: undefined, @@ -33,10 +34,12 @@ class Marpit { * @param {boolean} [opts.backgroundSyntax=true] Support markdown image syntax * with the alternate text including `bg`. Normally it converts into spot * directives about background image. If `inlineSVG` is enabled, it - * supports multiple image layouting. + * supports the advanced backgrounds. * @param {Element|Element[]} * [opts.container={@link module:element.marpitContainer}] Container * element(s) wrapping whole slide deck. + * @param {boolean} [opts.filters=true] Support filter syntax for markdown + * image. It can apply to inline image and the advanced backgrounds. * @param {string|Object|Array} [opts.markdown='commonmark'] markdown-it * initialize option(s). * @param {boolean} [opts.printable=true] Make style printable to PDF. @@ -87,7 +90,7 @@ class Marpit { .use(marpItApplyDirectives) .use(marpItSlideContainer, this.slideContainers) .use(marpItContainer, this.containers) - .use(marpItParseImage) + .use(marpItParseImage, { filters: this.options.filters }) .use(marpItSweep) .use(marpItInlineSVG, this) From 69710660b937dec22276ec18287ce2aa1988566b Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Thu, 3 May 2018 11:32:56 +0900 Subject: [PATCH 3/9] Update README.md --- README.md | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bf677ad5..5eded574 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,9 @@ We provide a background image syntax to specify slide's background through Markd ![bg](https://example.com/background.jpg) ``` -You can disable by `backgroundSyntax: false` in Marpit constructor option. +When you defined 2 or more background images in a slide, Marpit will show the last defined image only. If you want to show multiple images, try [the advanced backgrounds](#advanced-backgrounds-with-inline-svg-mode) by enabling [inline SVG mode](#inline-svg-slide-experimental). + +You can disable by `backgroundSyntax: false` in Marpit constructor option if you not want the `bg` syntax. #### Resize images @@ -108,7 +110,9 @@ When you remove the underbar, the background would apply to current and _the fol #### Advanced backgrounds with inline SVG mode -The advanced backgrounds will work only with [`inlineSVG: true`](#inline-svg-slide-experimental). It supports multiple background images. +The advanced backgrounds will work _only with [`inlineSVG: true`](#inline-svg-slide-experimental)_. It supports multiple background images and image filters. + +##### Multiple background images ``` ![bg](https://example.com/backgroundA.jpg) @@ -117,9 +121,39 @@ The advanced backgrounds will work only with [`inlineSVG: true`](#inline-svg-sli These images will arrange in a row. +### Image filters + +You can apply CSS filters to image through markdown image syntax. Include `(:(,...))` to the space-separated alternate text of image syntax. + +Filters can use in the inline image and [the advanced backgrounds](#advanced-backgrounds-with-inline-svg-mode). You can disable this feature with `filters: false` in Marpit constructor option. + +#### Filters + +We are following the function of the [`filter` style](https://developer.mozilla.org/en-US/docs/Web/CSS/filter). + +| Markdown | (with arguments) | `filter` style | +| ------------------ | -------------------------------------------- | ------------------------------------------- | +| `![blur]()` | `![blur:10px]()` | `blur(10px)` | +| `![brightness]()` | `![brightness:1.5]()` | `brightness(1.5)` | +| `![contrast]()` | `![contrast:200%]()` | `contrast(200%)` | +| `![drop-shadow]()` | `![drop-shadow:0,5px,10px,rgba(0,0,0,.4)]()` | `drop-shadow(0 5px 10px rgba(0, 0, 0, .4))` | +| `![grayscale]()` | `![grayscale:1]()` | `grayscale(1)` | +| `![hue-rotate]()` | `![hue-rotate:180deg]()` | `hue-rotate(180deg)` | +| `![invert]()` | `![invert:100%]()` | `invert(100%)` | +| `![opacity]()` | `![opacity:.5]()` | `opacity(.5)` | +| `![saturate]()` | `![saturate:2.0]()` | `saturate(2.0)` | +| `![sepia]()` | `![sepia:1.0]()` | `sepia(1.0)` | + +Marpit will use the default arguments shown in above when you omit arguments. + +Naturally multiple filters can apply to a image. + +```markdown +![brightness:.8 sepia:50%](https://example.com/image.jpg) +``` + ### ToDo -* [ ] Background filters in advanced backgrounds * [ ] Header and footer directive * [ ] Slide page number From 1d4ec0359272ccb718b06b6064fb698b4371acab Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Fri, 4 May 2018 16:09:49 +0900 Subject: [PATCH 4/9] Add test case about image filters --- src/markdown/background_image.js | 1 + test/markdown/background_image.js | 65 ++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/markdown/background_image.js b/src/markdown/background_image.js index c6f7acdc..80490f01 100644 --- a/src/markdown/background_image.js +++ b/src/markdown/background_image.js @@ -92,6 +92,7 @@ function backgroundImage(md) { tb.children.forEach(t => { if (t.type !== 'image') return + const { background, backgroundSize, diff --git a/test/markdown/background_image.js b/test/markdown/background_image.js index 009c75f6..46035b5d 100644 --- a/test/markdown/background_image.js +++ b/test/markdown/background_image.js @@ -16,14 +16,14 @@ describe('Marpit background image plugin', () => { options: { inlineSVG: svg }, }) - const md = (svg = false) => + const md = (svg = false, filters = false) => new MarkdownIt() .use(comment) .use(slide) .use(parseDirectives, marpitStub(svg)) .use(applyDirectives) .use(inlineSVG, marpitStub(svg)) - .use(parseImage) + .use(parseImage, { filters }) .use(backgroundImage) const bgDirective = (url, mdInstance) => { @@ -99,7 +99,7 @@ describe('Marpit background image plugin', () => { }) context('with inline SVG (Advanced background mode)', () => { - const mdSVG = md(true) + const mdSVG = (filters = false) => md(true, filters) const $load = html => cheerio.load(html, { lowerCaseAttributeNames: false, @@ -107,7 +107,7 @@ describe('Marpit background image plugin', () => { }) it('renders the structure for advanced background to another foreignObject', () => { - const $ = $load(mdSVG.render('![bg](image)')) + const $ = $load(mdSVG().render('![bg](image)')) assert($('svg[viewBox="0 0 100 100"] > foreignObject').length === 2) const bg = $('svg > foreignObject:first-child') @@ -122,14 +122,14 @@ describe('Marpit background image plugin', () => { }) it('escapes doublequote to disallow XSS', () => { - const $ = $load(mdSVG.render('![bg](img"\\);color:#f00;--xss:url\\(")')) + const $ = $load(mdSVG().render('![bg](img"\\);color:#f00;--xss:url\\(")')) const style = $('figure').attr('style') assert(style !== 'background-image:("img");color:#f00;--xss:url("");') }) it('assigns data attribute to section element of the slide content', () => { - const $ = $load(mdSVG.render('![bg](image)\n\n# test')) + const $ = $load(mdSVG().render('![bg](image)\n\n# test')) const slideSection = $('svg > foreignObject:last-child > section') assert(slideSection.find('h1').length === 1) @@ -137,7 +137,9 @@ describe('Marpit background image plugin', () => { }) it("inherits slide section's style assigned by directive", () => { - const $ = $load(mdSVG.render(' ![bg](B)')) + const $ = $load( + mdSVG().render(' ![bg](B)') + ) const bgSection = $( 'section[data-marpit-advanced-background="background"]' ) @@ -146,7 +148,7 @@ describe('Marpit background image plugin', () => { }) it('renders multiple images', () => { - const $ = $load(mdSVG.render('![bg](A) ![bg](B)')) + const $ = $load(mdSVG().render('![bg](A) ![bg](B)')) const figures = $('figure') assert(figures.length === 2) @@ -155,7 +157,7 @@ describe('Marpit background image plugin', () => { }) it('assigns background-size style with resizing keyword / scale', () => { - const $ = $load(mdSVG.render('![bg fit](A) ![bg 50%](B)')) + const $ = $load(mdSVG().render('![bg fit](A) ![bg 50%](B)')) const styleA = $('figure:first-child').attr('style') const styleB = $('figure:last-child').attr('style') @@ -164,5 +166,50 @@ describe('Marpit background image plugin', () => { assert(styleB.includes('background-image:url("B");')) assert(styleB.includes('background-size:50%;')) }) + + context('when filters option of parse image plugin is enabled', () => { + it.only('assigns filter style with the function of filter', () => { + const filters = { + // with default attributes + blur: 'filter:blur(10px);', + brightness: 'filter:brightness(1.5);', + contrast: 'filter:contrast(2);', + 'drop-shadow': 'filter:drop-shadow(0 5px 10px rgba(0,0,0,.4));', + grayscale: 'filter:grayscale(1);', + 'hue-rotate': 'filter:hue-rotate(180deg);', + invert: 'filter:invert(1);', + opacity: 'filter:opacity(.5);', + saturate: 'filter:saturate(2);', + sepia: 'filter:sepia(1);', + + // with specified attributes + 'blur:20px': 'filter:blur(20px);', + 'brightness:200%': 'filter:brightness(200%);', + 'contrast:.5': 'filter:contrast(.5);', + 'drop-shadow:1em,1em': 'filter:drop-shadow(1em 1em);', + 'drop-shadow:0,0,10px': 'filter:drop-shadow(0 0 10px);', + 'drop-shadow:0,1px,2px,#f00': 'filter:drop-shadow(0 1px 2px #f00);', + 'drop-shadow:0,0,20px,rgba(64,64,64,.25)': + 'filter:drop-shadow(0 0 20px rgba(64,64,64,.25));', + 'grayscale:50%': 'filter:grayscale(50%);', + 'hue-rotate:90deg': 'filter:hue-rotate(90deg);', + 'invert:0.25': 'filter:invert(0.25);', + 'opacity:30%': 'filter:opacity(30%);', + 'saturate:123%': 'filter:saturate(123%);', + 'sepia:.5': 'filter:sepia(.5);', + } + + Object.keys(filters).forEach(filter => { + const $ = $load( + mdSVG(true).render(`![${filter}](a)\n![bg ${filter}](b)`) + ) + const inlineImageStyle = $('img').attr('style') + const bgImageStyle = $('img').attr('style') + + assert(inlineImageStyle.includes(filters[filter])) + assert(bgImageStyle.includes(filters[filter])) + }) + }) + }) }) }) From ff145431ac322ab88fd8247255b45c5724c23f8a Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Fri, 4 May 2018 16:10:42 +0900 Subject: [PATCH 5/9] Remove .only from added test case --- test/markdown/background_image.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/markdown/background_image.js b/test/markdown/background_image.js index 46035b5d..5220bfd6 100644 --- a/test/markdown/background_image.js +++ b/test/markdown/background_image.js @@ -168,7 +168,7 @@ describe('Marpit background image plugin', () => { }) context('when filters option of parse image plugin is enabled', () => { - it.only('assigns filter style with the function of filter', () => { + it('assigns filter style with the function of filter', () => { const filters = { // with default attributes blur: 'filter:blur(10px);', From fd079690b3916c3436e22dbbde7ecdc497b6a0b1 Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Fri, 4 May 2018 16:19:10 +0900 Subject: [PATCH 6/9] [ci skip] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9253a431..dc2e9e27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +* Implement CSS filter for image and advanced backgrounds ([#14](https://github.com/marp-team/marpit/pull/14)) * Fix PostCSS printable plugin to allow printing the advanced backgrounds ([#15](https://github.com/marp-team/marpit/pull/15)) ## v0.0.3 - 2018-05-02 From 543c1b01e48408d71ebca993c722bcdbe40d9e8c Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Fri, 4 May 2018 16:30:13 +0900 Subject: [PATCH 7/9] Add test case about multiple filters and XSS --- test/markdown/background_image.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/markdown/background_image.js b/test/markdown/background_image.js index 5220bfd6..93c2d371 100644 --- a/test/markdown/background_image.js +++ b/test/markdown/background_image.js @@ -197,6 +197,9 @@ describe('Marpit background image plugin', () => { 'opacity:30%': 'filter:opacity(30%);', 'saturate:123%': 'filter:saturate(123%);', 'sepia:.5': 'filter:sepia(.5);', + + // with multiple filters + 'brightness:.75 blur': 'filter:brightness(.75) blur(10px);', } Object.keys(filters).forEach(filter => { @@ -210,6 +213,17 @@ describe('Marpit background image plugin', () => { assert(bgImageStyle.includes(filters[filter])) }) }) + + it('sanitizes arguments', () => { + const xssImageMd = '![brightness:1);color:red;--xss:(](a)' + const $ = $load(mdSVG(true).render(xssImageMd)) + + assert( + !$('img') + .attr('style') + .includes('filter:brightness(1);color:red;--xss:();') + ) + }) }) }) }) From cb404279d4b72ac1a7c3071db79880c91dde1aa0 Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Fri, 4 May 2018 17:27:29 +0900 Subject: [PATCH 8/9] Escape the filter arguments to prevent XSS --- src/markdown/parse_image.js | 55 ++++++++++++++++++++----------- test/markdown/background_image.js | 25 ++++++++------ 2 files changed, 50 insertions(+), 30 deletions(-) diff --git a/src/markdown/parse_image.js b/src/markdown/parse_image.js index 974d1f78..ccb7b374 100644 --- a/src/markdown/parse_image.js +++ b/src/markdown/parse_image.js @@ -1,4 +1,6 @@ /** @module */ +const escapeStyle = target => + target.replace(/[\\;:()]/g, matched => `\\${matched[0]}`) /** * Marpit parse image plugin. @@ -24,46 +26,59 @@ function parseImage(md, opts = {}) { if (pluginOptions.filters) { // CSS filters optionMatchers.set(/^blur(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['blur', matches[1] || '10px']], + filters: [...meta.filters, ['blur', escapeStyle(matches[1] || '10px')]], })) optionMatchers.set(/^brightness(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['brightness', matches[1] || '1.5']], + filters: [ + ...meta.filters, + ['brightness', escapeStyle(matches[1] || '1.5')], + ], })) optionMatchers.set(/^contrast(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['contrast', matches[1] || '2']], + filters: [...meta.filters, ['contrast', escapeStyle(matches[1] || '2')]], })) optionMatchers.set( /^drop-shadow(?::(.+?),(.+?)(?:,(.+?))?(?:,(.+?))?)?$/, - (matches, meta) => ({ - filters: [ - ...meta.filters, - [ - 'drop-shadow', - matches - .slice(1) - .filter(v => v) - .join(' ') || '0 5px 10px rgba(0,0,0,.4)', + (matches, meta) => { + const args = matches + .slice(1) + .filter(v => v) + .map(arg => { + const colorFunc = arg.match(/^(rgba?|hsla?)\((.*)\)$/) + + return colorFunc + ? `${colorFunc[1]}(${escapeStyle(colorFunc[2])})` + : escapeStyle(arg) + }) + + return { + filters: [ + ...meta.filters, + ['drop-shadow', args.join(' ') || '0 5px 10px rgba(0,0,0,.4)'], ], - ], - }) + } + } ) optionMatchers.set(/^grayscale(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['grayscale', matches[1] || '1']], + filters: [...meta.filters, ['grayscale', escapeStyle(matches[1] || '1')]], })) optionMatchers.set(/^hue-rotate(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['hue-rotate', matches[1] || '180deg']], + filters: [ + ...meta.filters, + ['hue-rotate', escapeStyle(matches[1] || '180deg')], + ], })) optionMatchers.set(/^invert(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['invert', matches[1] || '1']], + filters: [...meta.filters, ['invert', escapeStyle(matches[1] || '1')]], })) optionMatchers.set(/^opacity(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['opacity', matches[1] || '.5']], + filters: [...meta.filters, ['opacity', escapeStyle(matches[1] || '.5')]], })) optionMatchers.set(/^saturate(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['saturate', matches[1] || '2']], + filters: [...meta.filters, ['saturate', escapeStyle(matches[1] || '2')]], })) optionMatchers.set(/^sepia(?::(.+))?$/, (matches, meta) => ({ - filters: [...meta.filters, ['sepia', matches[1] || '1']], + filters: [...meta.filters, ['sepia', escapeStyle(matches[1] || '1')]], })) } diff --git a/test/markdown/background_image.js b/test/markdown/background_image.js index 93c2d371..ef187c77 100644 --- a/test/markdown/background_image.js +++ b/test/markdown/background_image.js @@ -189,8 +189,8 @@ describe('Marpit background image plugin', () => { 'drop-shadow:1em,1em': 'filter:drop-shadow(1em 1em);', 'drop-shadow:0,0,10px': 'filter:drop-shadow(0 0 10px);', 'drop-shadow:0,1px,2px,#f00': 'filter:drop-shadow(0 1px 2px #f00);', - 'drop-shadow:0,0,20px,rgba(64,64,64,.25)': - 'filter:drop-shadow(0 0 20px rgba(64,64,64,.25));', + 'drop-shadow:0,0,20px,hsla(0,100%,50%,.5)': + 'filter:drop-shadow(0 0 20px hsla(0,100%,50%,.5));', 'grayscale:50%': 'filter:grayscale(50%);', 'hue-rotate:90deg': 'filter:hue-rotate(90deg);', 'invert:0.25': 'filter:invert(0.25);', @@ -215,14 +215,19 @@ describe('Marpit background image plugin', () => { }) it('sanitizes arguments', () => { - const xssImageMd = '![brightness:1);color:red;--xss:(](a)' - const $ = $load(mdSVG(true).render(xssImageMd)) - - assert( - !$('img') - .attr('style') - .includes('filter:brightness(1);color:red;--xss:();') - ) + const xsses = { + 'brightness:1);color:red;--xss:(': + 'filter:brightness(1);color:red;--xss:();', + 'drop-shadow:0,0,0,rgba());color:red;--xss:(': + 'filter:drop-shadow(0,0,0,rgba());color:red;--xss:();', + } + + Object.keys(xsses).forEach(filter => { + const $ = $load(mdSVG(true).render(`![${filter}](a)`)) + const style = $('img').attr('style') + + assert(!style.includes(xsses[filter])) + }) }) }) }) From 2c03ee05bbcdc472ca3061ac124abb7ca2f29d33 Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Fri, 4 May 2018 17:42:24 +0900 Subject: [PATCH 9/9] Add test case of Marpit class about filters instance option --- test/marpit.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/marpit.js b/test/marpit.js index 4eebc1ee..812a230a 100644 --- a/test/marpit.js +++ b/test/marpit.js @@ -139,6 +139,24 @@ describe('Marpit', () => { ) }) }) + + context('with filters option in instance', () => { + const instance = filters => new Marpit({ filters }) + + it('does not apply filter style when filters is false', () => { + const $ = cheerio.load(instance(false).render('![blur](test)').html) + const style = $('img').attr('style') || '' + + assert(!style.includes('filter:blur')) + }) + + it('applies filter style when filters is true', () => { + const $ = cheerio.load(instance(true).render('![blur](test)').html) + const style = $('img').attr('style') || '' + + assert(style.includes('filter:blur')) + }) + }) }) describe('#renderMarkdown', () => {