Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add mathjax support #165

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
"highlight.js": "^9.18.1",
"katex": "^0.11.1",
"markdown-it-emoji": "^1.4.0",
"mathjax-full": "^3.0.5",
"postcss": "^7.0.27",
"postcss-minify-params": "^4.0.2",
"postcss-minify-selectors": "^4.0.2",
Expand Down
10 changes: 8 additions & 2 deletions src/marp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as emojiPlugin from './emoji/emoji'
import * as fittingPlugin from './fitting/fitting'
import * as htmlPlugin from './html/html'
import * as mathPlugin from './math/math'
import * as mathjaxPlugin from './mathjax/math'
import * as scriptPlugin from './script/script'
import * as sizePlugin from './size/size'
import defaultTheme from '../themes/default.scss'
Expand All @@ -25,6 +26,7 @@ export interface MarpOptions extends Options {
| { [attr: string]: boolean | ((value: string) => string) }
}
markdown?: object
mathjax?: boolean
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that interface of constructor option is not intuitive. math: 'mathjax' would be better than an individual option.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice idea. Let me confirm you.

math: 'katex' # enable katex as same as `true`
math: true # enable katex for backward compatibility
math: { ... } # enable katex with options
math: 'mathjax' # enable mathjax

Do you want to do like this?

math?: mathPlugin.MathOptions
minifyCSS?: boolean
script?: boolean | scriptPlugin.ScriptOptions
Expand All @@ -47,6 +49,7 @@ export class Marp extends Marpit {
super({
inlineSVG: true,
looseYAML: true,
mathjax: false,
math: true,
minifyCSS: true,
script: true,
Expand Down Expand Up @@ -86,7 +89,10 @@ export class Marp extends Marpit {

md.use(htmlPlugin.markdown)
.use(emojiPlugin.markdown)
.use(mathPlugin.markdown, (flag) => (this.renderedMath = flag))
.use(
this.options.mathjax ? mathjaxPlugin.markdown : mathPlugin.markdown,
(flag) => (this.renderedMath = flag)
)
.use(fittingPlugin.markdown)
.use(sizePlugin.markdown)
.use(scriptPlugin.markdown)
Expand Down Expand Up @@ -126,7 +132,7 @@ export class Marp extends Marpit {
if (typeof math === 'object') path = math.katexFontPath || undefined

// Add KaTeX css
prepend(mathPlugin.css(path))
prepend(this.options.mathjax ? mathjaxPlugin.css() : mathPlugin.css(path))
}

return base
Expand Down
206 changes: 206 additions & 0 deletions src/mathjax/math.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import marpitPlugin from '@marp-team/marpit/plugin'

import { mathjax } from 'mathjax-full/js/mathjax'
import { TeX } from 'mathjax-full/js/input/tex'
import { SVG } from 'mathjax-full/js/output/svg'
import { liteAdaptor } from 'mathjax-full/js/adaptors/liteAdaptor'
import { RegisterHTMLHandler } from 'mathjax-full/js/handlers/html'
import { AllPackages } from 'mathjax-full/js/input/tex/AllPackages'

const adaptor = liteAdaptor()
RegisterHTMLHandler(adaptor)
const tex = new TeX({ packages: AllPackages })
const svg = new SVG({ fontCache: 'none' })
const mathDocument = mathjax.document('', { InputJax: tex, OutputJax: svg })

interface MathOptionsInterface {
katexOption?: object
}

export type MathOptions = boolean | MathOptionsInterface

/**
* marp-core math plugin
*
* It is implemented based on markdown-it-katex plugin. However, that is no
* longer maintained by author. So we have ported math typesetting parser.
*
* @see https://github.com/waylonflinn/markdown-it-katex
*/
export const markdown = marpitPlugin(
(md, updateState: (rendered: boolean) => void = () => {}) => {
const genOpts = (display: boolean) => {
const math: MathOptions = md.marpit.options.math

return {
...(typeof math === 'object' && typeof math.katexOption === 'object'
? math.katexOption
: {}),
display,
}
}

md.core.ruler.before('block', 'marp_math_initialize', (state) => {
if (state.inlineMode) return

updateState(false)

if (md.marpit.options.math) {
md.block.ruler.enable('marp_math_block')
md.inline.ruler.enable('marp_math_inline')
} else {
md.block.ruler.disable('marp_math_block')
md.inline.ruler.disable('marp_math_inline')
}
})

// Inline
md.inline.ruler.after('escape', 'marp_math_inline', (state, silent) => {
if (parseInlineMath(state, silent)) {
updateState(true)
return true
}
return false
})

md.renderer.rules.marp_math_inline = (tokens, idx) => {
const { content } = tokens[idx]

try {
return adaptor.outerHTML(mathDocument.convert(content, genOpts(false)))
} catch (e) {
console.warn(e)
return content
}
}

// Block
md.block.ruler.after(
'blockquote',
'marp_math_block',
(state, start, end, silent) => {
if (parseMathBlock(state, start, end, silent)) {
updateState(true)
return true
}
return false
},
{ alt: ['paragraph', 'reference', 'blockquote', 'list'] }
)

md.renderer.rules.marp_math_block = (tokens, idx) => {
const { content } = tokens[idx]

try {
return `<p>${adaptor.outerHTML(
mathDocument.convert(content, genOpts(true))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By comparing to KaTeX rendering, the rendered math block is not centering.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solved, check it.

const { Marp } = require("./lib/marp.js")
const marp = new Marp({ math: true, mathjax: false})

const md = `
# Elementary Math

$$
1+1 = 2
$$
`
const r = marp.render(md)
console.log(r.html + `<style>${r.css}</style>`)

)}</p>`
} catch (e) {
console.warn(e)
return `<p>${content}</p>`
}
}
}
)

export function css(): string {
return adaptor.textContent(svg.styleSheet(mathDocument) as any)
}

function isValidDelim(state, pos = state.pos) {
const ret = { openable: true, closable: true }
const { posMax, src } = state
const prev = pos > 0 ? src.charCodeAt(pos - 1) : -1
const next = pos + 1 <= posMax ? src.charCodeAt(pos + 1) : -1

if (next === 0x20 || next === 0x09) ret.openable = false
if (prev === 0x20 || prev === 0x09 || (next >= 0x30 && next <= 0x39)) {
ret.closable = false
}

return ret
}

function parseInlineMath(state, silent) {
const { src, pos } = state
if (src[pos] !== '$') return false

const addPending = (stt: string) => (state.pending += stt)
const found = (manipulation: () => void, newPos: number) => {
if (!silent) manipulation()
state.pos = newPos
return true
}

const start = pos + 1
if (!isValidDelim(state).openable) return found(() => addPending('$'), start)

let match = start
while ((match = src.indexOf('$', match)) !== -1) {
let dollarPos = match - 1
while (src[dollarPos] === '\\') dollarPos -= 1

if ((match - dollarPos) % 2 === 1) break
match += 1
}

if (match === -1) return found(() => addPending('$'), start)
if (match - start === 0) return found(() => addPending('$$'), start + 1)
if (!isValidDelim(state, match).closable) {
return found(() => addPending('$'), start)
}

return found(() => {
const token = state.push('marp_math_inline', 'math', 0)
token.markup = '$'
token.content = src.slice(start, match)
}, match + 1)
}

function parseMathBlock(state, start, end, silent) {
const { blkIndent, bMarks, eMarks, src, tShift } = state
let pos = bMarks[start] + tShift[start]
let max = eMarks[start]

if (pos + 2 > max || src.slice(pos, pos + 2) !== '$$') return false
if (silent) return true

pos += 2

let firstLine = src.slice(pos, max)
let lastLine
let found = firstLine.trim().slice(-2) === '$$'

if (found) firstLine = firstLine.trim().slice(0, -2)

let next = start
for (; !found; ) {
next += 1
if (next >= end) break

pos = bMarks[next] + tShift[next]
max = eMarks[next]
if (pos < max && tShift[next] < blkIndent) break

const target = src.slice(pos, max).trim()

if (target.slice(-2) === '$$') {
found = true
lastLine = src.slice(pos, src.slice(0, max).lastIndexOf('$$'))
}
}

state.line = next + 1

const token = state.push('marp_math_block', 'math', 0)
token.block = true
token.content = ''
token.map = [start, state.line]
token.markup = '$$'

if (firstLine?.trim()) token.content += `${firstLine}\n`
token.content += state.getLines(start + 1, next, tShift[start], true)
if (lastLine?.trim()) token.content += lastLine

return true
}
74 changes: 74 additions & 0 deletions test/fitting/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,78 @@ describe('Fitting observer', () => {
expect(mathSvg.getAttribute('viewBox')).toBe('0 0 60 100')
})
})

context('when the auto-scalable elements is rendered with MathJax', () => {
let codeSvg: SVGSVGElement
let codePre: HTMLPreElement
let codeContent: HTMLSpanElement
let mathSvg: SVGSVGElement
let mathP: HTMLParagraphElement
let mathContent: HTMLSpanElement

beforeEach(() => {
document.body.innerHTML = new Marp({ mathjax: true }).render(
'```\nauto-scalble\n```\n\n$$ auto-scalable $$'
).html

codeSvg = document.querySelector<SVGSVGElement>(
'svg[data-marp-fitting-code]'
)!
codePre = document.querySelector<HTMLPreElement>('section pre')!
codeContent = codeSvg.querySelector<HTMLSpanElement>(
'span[data-marp-fitting-svg-content]'
)!
mathSvg = document.querySelector<SVGSVGElement>(
'svg[data-marp-fitting-math]'
)!
mathP = <HTMLParagraphElement>mathSvg.parentElement
mathContent = <HTMLSpanElement>(
mathSvg.querySelector('span[data-marp-fitting-svg-content]')!
)

setContentSize(codeContent, 200, 100)
setContentSize(mathContent, 50, 100)
})

const setClientWidth = (target, clientWidth) =>
Object.defineProperty(target, 'clientWidth', {
configurable: true,
get: () => clientWidth,
})

it("restricts min width to <pre> element's width without padding", () => {
const computed = jest.spyOn(window, 'getComputedStyle')

computed.mockImplementation((): any => ({
paddingLeft: 0,
paddingRight: 0,
getPropertyValue: () => undefined,
}))

setClientWidth(codePre, 300) // pre width > code svg width
setClientWidth(mathP, 400) // p width > math svg width
fittingObserver()
expect(codeSvg.getAttribute('viewBox')).toBe('0 0 300 100')
expect(mathSvg.getAttribute('viewBox')).toBe('0 0 400 100')

setClientWidth(codePre, 100) // pre width < code svg width
setClientWidth(mathP, 25) // o width < math svg width
fittingObserver()
expect(codeSvg.getAttribute('viewBox')).toBe('0 0 200 100')
expect(mathSvg.getAttribute('viewBox')).toBe('0 0 50 100')

// Consider padding
computed.mockImplementation((): any => ({
paddingLeft: '50px',
paddingRight: '70px',
getPropertyValue: () => undefined,
}))

setClientWidth(codePre, 300) // 300 - 50 - 70 = 180px
setClientWidth(mathP, 180) // 180 - 50 - 70 = 60px
fittingObserver()
expect(codeSvg.getAttribute('viewBox')).toBe('0 0 200 100')
expect(mathSvg.getAttribute('viewBox')).toBe('0 0 60 100')
})
})
})
7 changes: 7 additions & 0 deletions test/marp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,13 @@ describe('Marp', () => {
expect($('.katex')).toHaveLength(2)
})

it('renders math typesetting by MathJax', () => {
const { html } = marp({ mathjax: true }).render(`${inline}\n\n${block}`)
const $ = cheerio.load(html)

expect($('.MathJax')).toHaveLength(2)
})

it('injects KaTeX css with replacing web font URL to CDN', () => {
const { css } = marp().render(block)
expect(css).toContain('.katex')
Expand Down
Loading