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 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 diff --git a/src/markdown/background_image.js b/src/markdown/background_image.js index 42079d43..80490f01 100644 --- a/src/markdown/background_image.js +++ b/src/markdown/background_image.js @@ -92,7 +92,14 @@ 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 +107,7 @@ function backgroundImage(md) { { url, size: size || backgroundSize || undefined, + filter, }, ] } @@ -149,9 +157,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..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. @@ -9,8 +11,77 @@ * * @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', escapeStyle(matches[1] || '10px')]], + })) + optionMatchers.set(/^brightness(?::(.+))?$/, (matches, meta) => ({ + filters: [ + ...meta.filters, + ['brightness', escapeStyle(matches[1] || '1.5')], + ], + })) + optionMatchers.set(/^contrast(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['contrast', escapeStyle(matches[1] || '2')]], + })) + optionMatchers.set( + /^drop-shadow(?::(.+?),(.+?)(?:,(.+?))?(?:,(.+?))?)?$/, + (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', escapeStyle(matches[1] || '1')]], + })) + optionMatchers.set(/^hue-rotate(?::(.+))?$/, (matches, meta) => ({ + filters: [ + ...meta.filters, + ['hue-rotate', escapeStyle(matches[1] || '180deg')], + ], + })) + optionMatchers.set(/^invert(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['invert', escapeStyle(matches[1] || '1')]], + })) + optionMatchers.set(/^opacity(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['opacity', escapeStyle(matches[1] || '.5')]], + })) + optionMatchers.set(/^saturate(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['saturate', escapeStyle(matches[1] || '2')]], + })) + optionMatchers.set(/^sepia(?::(.+))?$/, (matches, meta) => ({ + filters: [...meta.filters, ['sepia', escapeStyle(matches[1] || '1')]], + })) + } + md.inline.ruler2.push('marpit_parse_image', ({ tokens }) => { tokens.forEach(token => { if (token.type === 'image') { @@ -24,10 +95,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};`) + } } }) }) 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) diff --git a/test/markdown/background_image.js b/test/markdown/background_image.js index 009c75f6..ef187c77 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,69 @@ 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('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,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);', + '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 => { + 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])) + }) + }) + + it('sanitizes arguments', () => { + 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])) + }) + }) + }) }) }) 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', () => {