/** @module */
import colorString from 'color-string'
import marpitPlugin from '../../plugin'
const escape = (target) =>
target.replace(
/[\\;:()]/g,
(matched) => `\\${matched[0].codePointAt(0).toString(16)} `
)
const optionMatchers = new Map()
// The scale percentage for resize background
optionMatchers.set(/^(\d*\.)?\d+%$/, (matches) => ({ size: matches[0] }))
// width and height
const normalizeLength = (v) => `${v}${/^(\d*\.)?\d+$/.test(v) ? 'px' : ''}`
optionMatchers.set(
/^w(?:idth)?:((?:\d*\.)?\d+(?:%|ch|cm|em|ex|in|mm|pc|pt|px)?|auto)$/,
(matches) => ({ width: normalizeLength(matches[1]) })
)
optionMatchers.set(
/^h(?:eight)?:((?:\d*\.)?\d+(?:%|ch|cm|em|ex|in|mm|pc|pt|px)?|auto)$/,
(matches) => ({ height: normalizeLength(matches[1]) })
)
// CSS filters
optionMatchers.set(/^blur(?::(.+))?$/, (matches, meta) => ({
filters: [...meta.filters, ['blur', escape(matches[1] || '10px')]],
}))
optionMatchers.set(/^brightness(?::(.+))?$/, (matches, meta) => ({
filters: [...meta.filters, ['brightness', escape(matches[1] || '1.5')]],
}))
optionMatchers.set(/^contrast(?::(.+))?$/, (matches, meta) => ({
filters: [...meta.filters, ['contrast', escape(matches[1] || '2')]],
}))
optionMatchers.set(
/^drop-shadow(?::(.+?),(.+?)(?:,(.+?))?(?:,(.+?))?)?$/,
(matches, meta) => {
const args = []
for (const arg of matches.slice(1)) {
if (arg) {
const colorFunc = arg.match(
/^(rgba?|hsla?|hwb|(?:ok)?(?:lab|lch)|color)\((.*)\)$/
)
args.push(
colorFunc ? `${colorFunc[1]}(${escape(colorFunc[2])})` : escape(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', escape(matches[1] || '1')]],
}))
optionMatchers.set(/^hue-rotate(?::(.+))?$/, (matches, meta) => ({
filters: [...meta.filters, ['hue-rotate', escape(matches[1] || '180deg')]],
}))
optionMatchers.set(/^invert(?::(.+))?$/, (matches, meta) => ({
filters: [...meta.filters, ['invert', escape(matches[1] || '1')]],
}))
optionMatchers.set(/^opacity(?::(.+))?$/, (matches, meta) => ({
filters: [...meta.filters, ['opacity', escape(matches[1] || '.5')]],
}))
optionMatchers.set(/^saturate(?::(.+))?$/, (matches, meta) => ({
filters: [...meta.filters, ['saturate', escape(matches[1] || '2')]],
}))
optionMatchers.set(/^sepia(?::(.+))?$/, (matches, meta) => ({
filters: [...meta.filters, ['sepia', escape(matches[1] || '1')]],
}))
/**
* Marpit image parse plugin.
*
* Parse image tokens and store the result into `marpitImage` meta. It has an
* image url and options. The alternative text is regarded as space-separated
* options.
*
* @function parseImage
* @param {MarkdownIt} md markdown-it instance.
*/
function _parseImage(md) {
const { process } = md.core
// Store original URL, for the color shorthand.
// (Avoid a side effect from link normalization)
let originalURLMap
let refCount = 0
const finalizeTokenAttr = (token, state) => {
// Convert imprimitive attribute value into primitive string
if (token.attrs && Array.isArray(token.attrs)) {
token.attrs = token.attrs.map(([name, value]) => [name, value.toString()])
}
// Apply finalization recursively to inline tokens
if (token.type === 'inline') {
for (const t of token.children) finalizeTokenAttr(t, state)
}
// Re-generate the alt text of image token to remove Marpit specific options
if (token.type === 'image' && token.meta && token.meta.marpitImage) {
let updatedAlt = ''
let hasConsumed = false
for (const opt of token.meta.marpitImage.options) {
if (opt.consumed) {
hasConsumed = true
} else {
updatedAlt += opt.leading + opt.content
}
}
if (hasConsumed) {
let newTokens = []
md.inline.parse(updatedAlt.trimLeft(), state.md, state.env, newTokens)
token.children = newTokens
}
}
}
md.core.process = (state) => {
const { normalizeLink } = md
// Prevent reset of WeakMap caused by calling core process internally
if (refCount === 0) originalURLMap = new WeakMap()
try {
md.normalizeLink = (url) => {
const imprimitiveUrl = new String(normalizeLink.call(md, url))
originalURLMap.set(imprimitiveUrl, url)
return imprimitiveUrl
}
refCount += 1
return process.call(md.core, state)
} finally {
refCount -= 1
md.normalizeLink = normalizeLink
if (refCount === 0) {
// Apply finalization for every tokens
for (const token of state.tokens) finalizeTokenAttr(token, state)
}
}
}
md.inline.ruler2.push('marpit_parse_image', ({ tokens }) => {
for (const token of tokens) {
if (token.type === 'image') {
// Parse alt text as options
const optsBase = token.content.split(/(\s+)/)
let currentIdx = 0
let leading = ''
const options = optsBase.reduce((acc, opt, i) => {
if (i % 2 === 0 && opt.length > 0) {
currentIdx += leading.length
acc.push({
content: opt,
index: currentIdx,
leading,
consumed: false,
})
leading = ''
currentIdx += opt.length
} else {
leading += opt
}
return acc
}, [])
const url = token.attrGet('src')
const originalUrl = originalURLMap.has(url)
? originalURLMap.get(url)
: url
token.meta = token.meta || {}
token.meta.marpitImage = {
...(token.meta.marpitImage || {}),
url: url.toString(),
options,
}
// [DEPRECATED]
// Detect shorthand for setting color (Use value before normalization)
if (
!!colorString.get(originalUrl) ||
originalUrl.toLowerCase() === 'currentcolor'
) {
const replacedDirective = options.some((opt) => opt.content === 'bg')
? 'backgroundColor'
: 'color'
console.warn(
`Deprecation warning: Shorthand for setting colors via Markdown image syntax is deprecated now, and will remove in next major release. Please replace to a scoped local direcitve <!-- _${replacedDirective}: "${originalUrl}" -->, or use the scoped style <style scoped>.`
)
token.meta.marpitImage.color = originalUrl
token.hidden = true
}
// Parse keyword through matchers
for (const opt of options) {
for (const [regexp, mergeFunc] of optionMatchers) {
if (opt.consumed) continue
const matched = opt.content.match(regexp)
if (matched) {
opt.consumed = true
token.meta.marpitImage = {
...token.meta.marpitImage,
...mergeFunc(matched, {
filters: [],
...token.meta.marpitImage,
}),
}
}
}
}
}
}
})
}
export const parseImage = marpitPlugin(_parseImage)
export default parseImage