From de8d0e1a80b8374441e5efed494fc4513327b750 Mon Sep 17 00:00:00 2001 From: lnasrc Date: Tue, 8 Nov 2022 17:42:15 +0100 Subject: [PATCH] feat(auto-render): add option to ignore escaped special characters outside of LaTeX math mode (#437) --- contrib/auto-render/auto-render.js | 19 +- contrib/auto-render/splitAtDelimiters.js | 31 ++- contrib/auto-render/test/auto-render-spec.js | 223 ++++++++++++++++--- docs/autorender.md | 3 + 4 files changed, 233 insertions(+), 43 deletions(-) diff --git a/contrib/auto-render/auto-render.js b/contrib/auto-render/auto-render.js index eceee5b980..833b398d6a 100644 --- a/contrib/auto-render/auto-render.js +++ b/contrib/auto-render/auto-render.js @@ -7,18 +7,30 @@ import splitAtDelimiters from "./splitAtDelimiters"; * API, we should copy it before mutating. */ const renderMathInText = function(text, optionsCopy) { - const data = splitAtDelimiters(text, optionsCopy.delimiters); + const data = splitAtDelimiters(text, optionsCopy.delimiters, + optionsCopy.supportEscapedSpecialCharsInText); if (data.length === 1 && data[0].type === 'text') { // There is no formula in the text. // Let's return null which means there is no need to replace // the current text node with a new one. - return null; + if (!optionsCopy.supportEscapedSpecialCharsInText) { + return null; + } } const fragment = document.createDocumentFragment(); for (let i = 0; i < data.length; i++) { if (data[i].type === "text") { + if (optionsCopy.supportEscapedSpecialCharsInText) { + data[i].data = data[i].data.replace(/\\\$/g, '$'); + data[i].data = data[i].data.replace(/\\%/g, '%'); + data[i].data = data[i].data.replace(/\\_/g, '_'); + data[i].data = data[i].data.replace(/\\&/g, '&'); + data[i].data = data[i].data.replace(/\\#/g, '#'); + data[i].data = data[i].data.replace(/\\{/g, '{'); + data[i].data = data[i].data.replace(/\\}/g, '}'); + } fragment.appendChild(document.createTextNode(data[i].data)); } else { const span = document.createElement("span"); @@ -136,6 +148,9 @@ const renderMathInElement = function(elem, options) { // math elements within a single call to `renderMathInElement`. optionsCopy.macros = optionsCopy.macros || {}; + optionsCopy.supportEscapedSpecialCharsInText = + optionsCopy.supportEscapedSpecialCharsInText || false; + renderElem(elem, optionsCopy); }; diff --git a/contrib/auto-render/splitAtDelimiters.js b/contrib/auto-render/splitAtDelimiters.js index 21b59030a5..a55cb52be7 100644 --- a/contrib/auto-render/splitAtDelimiters.js +++ b/contrib/auto-render/splitAtDelimiters.js @@ -27,19 +27,38 @@ const findEndOfMath = function(delimiter, text, startIndex) { return -1; }; -const escapeRegex = function(string) { - return string.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); +const escapeRegex = function(string, supportEscapedSpecialCharsInText) { + if (supportEscapedSpecialCharsInText) { + if (string === "$") { + /* negative lookbehind to find any dollar not preceded by a + backslash */ + return "(? escapeRegex(x.left)).join("|") + ")" - ); + const regexLeft = new RegExp("(" + delimiters.map((x) => + escapeRegex(x.left, supportEscapedSpecialCharsInText), + ).join("|") + ")"); while (true) { index = text.search(regexLeft); diff --git a/contrib/auto-render/test/auto-render-spec.js b/contrib/auto-render/test/auto-render-spec.js index e4038b59aa..6c08f840ee 100644 --- a/contrib/auto-render/test/auto-render-spec.js +++ b/contrib/auto-render/test/auto-render-spec.js @@ -6,14 +6,15 @@ import renderMathInElement from "../auto-render"; beforeEach(function() { expect.extend({ - toSplitInto: function(actual, result, delimiters) { + toSplitInto: function(actual, result, + delimiters, supportEscapedSpecialCharsInText) { const message = { pass: true, message: () => "'" + actual + "' split correctly", }; - const split = - splitAtDelimiters(actual, delimiters); + const split = splitAtDelimiters(actual, + delimiters, supportEscapedSpecialCharsInText); if (split.length !== result.length) { message.pass = false; @@ -65,7 +66,8 @@ describe("A delimiter splitter", function() { ], [ {left: "(", right: ")", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); }); it("doesn't create a math node with only one left delimiter", function() { @@ -76,7 +78,8 @@ describe("A delimiter splitter", function() { ], [ {left: "(", right: ")", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); }); it("doesn't split when there's only a right delimiter", function() { @@ -86,7 +89,8 @@ describe("A delimiter splitter", function() { ], [ {left: "(", right: ")", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); }); it("splits when there are both delimiters", function() { @@ -99,7 +103,8 @@ describe("A delimiter splitter", function() { ], [ {left: "(", right: ")", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); }); it("splits on multi-character delimiters", function() { @@ -112,7 +117,8 @@ describe("A delimiter splitter", function() { ], [ {left: "[[", right: "]]", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); expect("hello \\begin{equation} world \\end{equation} boo").toSplitInto( [ {type: "text", data: "hello "}, @@ -124,7 +130,8 @@ describe("A delimiter splitter", function() { [ {left: "\\begin{equation}", right: "\\end{equation}", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); }); it("splits multiple times", function() { @@ -140,7 +147,8 @@ describe("A delimiter splitter", function() { ], [ {left: "(", right: ")", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); }); it("leaves the ending when there's only a left delimiter", function() { @@ -154,7 +162,8 @@ describe("A delimiter splitter", function() { ], [ {left: "(", right: ")", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); }); it("doesn't split when close delimiters are in {}s", function() { @@ -167,7 +176,8 @@ describe("A delimiter splitter", function() { ], [ {left: "(", right: ")", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); expect("hello ( world { { } ) } ) boo").toSplitInto( [ @@ -178,7 +188,8 @@ describe("A delimiter splitter", function() { ], [ {left: "(", right: ")", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); }); it("correctly processes sequences of $..$", function() { @@ -193,7 +204,8 @@ describe("A delimiter splitter", function() { ], [ {left: "$", right: "$", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); }); it("doesn't split at escaped delimiters", function() { @@ -206,18 +218,44 @@ describe("A delimiter splitter", function() { ], [ {left: "(", right: ")", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); - /* TODO(emily): make this work maybe? - expect("hello \\( ( world ) boo").toSplitInto( - "(", ")", - [ - {type: "text", data: "hello \\( "}, - {type: "math", data: " world ", - rawData: "( world )", display: false}, - {type: "text", data: " boo"}, - ]); - */ + expect("hello ( world \\) ) boo").toSplitInto( + [ + {type: "text", data: "hello "}, + {type: "math", data: " world \\) ", + rawData: "( world \\) )", display: false}, + {type: "text", data: " boo"}, + ], + [ + {left: "(", right: ")", display: false}, + ], + /* supportEscapedSpecialCharsInText */ true); + + expect("hello \\( ( world ) boo").toSplitInto( + [ + {type: "text", data: "hello \\"}, + {type: "math", data: " ( world ", + rawData: "( ( world )", display: false}, + {type: "text", data: " boo"}, + ], + [ + {left: "(", right: ")", display: false}, + ], + /* supportEscapedSpecialCharsInText */ false); + + expect("hello \\( ( world ) boo").toSplitInto( + [ + {type: "text", data: "hello \\( "}, + {type: "math", data: " world ", + rawData: "( world )", display: false}, + {type: "text", data: " boo"}, + ], + [ + {left: "(", right: ")", display: false}, + ], + /* supportEscapedSpecialCharsInText */ true); }); it("splits when the right and left delimiters are the same", function() { @@ -230,7 +268,77 @@ describe("A delimiter splitter", function() { ], [ {left: "$", right: "$", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); + }); + + it("doesn't split at escaped delimiters in text mode", function() { + expect( + "I give you 2\\$ now if you can solve $y = x^{2}$ and 3\\$ tomorrow.", + ).toSplitInto( + [ + {type: "text", data: "I give you 2\\$ now if you can solve "}, + {type: "math", data: "y = x^{2}", + rawData: "$y = x^{2}$", display: false}, + {type: "text", data: " and 3\\$ tomorrow."}, + ], + [ + {left: "$$", right: "$$", display: true}, + {left: "$", right: "$", display: false}, + {left: "\\(", right: "\\)", display: false}, + {left: "\\[", right: "\\]", display: true}, + ], + /* supportEscapedSpecialCharsInText */ true, + ); + + expect( + "I give you 2\\$ now if you can solve $y = x^{2}$ and 3\\$ tomorrow."). + toSplitInto( + [ + {type: "text", data: "I give you 2\\"}, + {type: "math", data: " now if you can solve ", + rawData: "$ now if you can solve $", display: false}, + {type: "text", data: "y = x^{2}"}, + {type: "text", data: "$ and 3\\$ tomorrow."}, + ], + [ + {left: '$$', right: '$$', display: true}, + {left: '$', right: '$', display: false}, + {left: '\\(', right: '\\)', display: false}, + {left: '\\[', right: '\\]', display: true}, + ], + /* supportEscapedSpecialCharsInText */ false); + + expect( + "Escapable characters in text mode: \ + \\$ \\% \\_ \\& \\# and in math mode: \ + $START_{1} \\$ \\% \\_ \\& \\# END_{1} \ + \text{, and in inlined text: \\$ \\% \\_ \\& \\# DONE}$, \ + thanks!"). + toSplitInto( + [ + { + type: "text", + data: "Escapable characters in text mode: \ + \\$ \\% \\_ \\& \\# and in math mode: ", + }, + { + type: "math", + data: "START_{1} \\$ \\% \\_ \\& \\# END_{1} \ + \text{, and in inlined text: \\$ \\% \\_ \\& \\# DONE}", + rawData: "$START_{1} \\$ \\% \\_ \\& \\# END_{1} \ + \text{, and in inlined text: \\$ \\% \\_ \\& \\# DONE}$", + display: false, + }, + {type: "text", data: ", thanks!"}, + ], + [ + {left: '$$', right: '$$', display: true}, + {left: '$', right: '$', display: false}, + {left: '\\(', right: '\\)', display: false}, + {left: '\\[', right: '\\]', display: true}, + ], + /* supportEscapedSpecialCharsInText */ true); }); it("ignores \\$", function() { @@ -241,14 +349,26 @@ describe("A delimiter splitter", function() { ], [ {left: "$", right: "$", display: false}, - ]); + ], + /* supportEscapedSpecialCharsInText */ false); + + expect("$x = \\$5$").toSplitInto( + [ + {type: "math", data: "x = \\$5", + rawData: "$x = \\$5$", display: false}, + ], + [ + {left: "$", right: "$", display: false}, + ], + /* supportEscapedSpecialCharsInText */ true); }); it("remembers which delimiters are display-mode", function() { - const startData = "hello ( world ) boo"; - - expect(splitAtDelimiters(startData, - [{left:"(", right:")", display:true}])).toEqual( + expect(splitAtDelimiters("hello ( world ) boo", + [ + {left: "(", right: ")", display: true}, + ], + /* supportEscapedSpecialCharsInText */ false)).toEqual( [ {type: "text", data: "hello "}, {type: "math", data: " world ", @@ -262,7 +382,8 @@ describe("A delimiter splitter", function() { [ {left:"\\(", right:"\\)", display:false}, {left:"$", right:"$", display:false}, - ])).toEqual( + ], + /* supportEscapedSpecialCharsInText */ false)).toEqual( [ {type: "math", data: "\\fbox{\\(hi\\)}", rawData: "$\\fbox{\\(hi\\)}$", display: false}, @@ -271,7 +392,8 @@ describe("A delimiter splitter", function() { [ {left:"\\(", right:"\\)", display:false}, {left:"$", right:"$", display:false}, - ])).toEqual( + ], + /* supportEscapedSpecialCharsInText */ false)).toEqual( [ {type: "math", data: "\\fbox{$hi$}", rawData: "\\(\\fbox{$hi$}\\)", display: false}, @@ -283,7 +405,8 @@ describe("A delimiter splitter", function() { [ {left:"$$", right:"$$", display:true}, {left:"$", right:"$", display:false}, - ])).toEqual( + ], + /* supportEscapedSpecialCharsInText */ false)).toEqual( [ {type: "math", data: "hello", rawData: "$hello$", display: false}, @@ -295,7 +418,8 @@ describe("A delimiter splitter", function() { [ {left:"$$", right:"$$", display:true}, {left:"$", right:"$", display:false}, - ])).toEqual( + ], + /* supportEscapedSpecialCharsInText */ false)).toEqual( [ {type: "math", data: "hello", rawData: "$hello$", display: false}, @@ -361,3 +485,32 @@ describe("Parse adjacent text nodes", function() { expect(el).toStrictEqual(el2); }); }); + +describe("support escaped special chars in text", function() { + it("renders escaped special chars in text and math", function() { + const textNodes = [ "Escapable characters in text mode: ", + "\\$ \\% \\_ \\& \\# ", + "and in math mode:", + "$ \\$ \\% \\_ \\& \\# \text{, and in inlined text: ", + "\\$ \\% \\_ \\& \\# } $,", + "thanks!" ]; + const el = document.createElement('div'); + for (let i = 0; i < textNodes.length; i++) { + const txt = document.createTextNode(textNodes[i]); + el.appendChild(txt); + } + const el2 = document.createElement('div'); + const txt = document.createTextNode(textNodes.join('')); + el2.appendChild(txt); + const delimiters = [{left: "$", right: "$", display: false}]; + renderMathInElement(el, { + delimiters, + supportEscapedSpecialCharsInText: true, + }); + renderMathInElement(el2, { + delimiters, + supportEscapedSpecialCharsInText: true, + }); + expect(el).toStrictEqual(el2); + }); +}); diff --git a/docs/autorender.md b/docs/autorender.md index a4aa48f090..f1fb3b2567 100644 --- a/docs/autorender.md +++ b/docs/autorender.md @@ -128,6 +128,9 @@ in addition to five auto-render-specific keys: - `preProcess`: A callback function, `(math: string) => string`, used to process math expressions before rendering. +- `supportEscapedSpecialCharsInText`: `boolean` (default: `false`). If `true`, `\$` are ignored outside of LaTeX math expressions. + For example: `Please enjoy this 2\$ coffee and I'll explain why $e = mc^2$.` + The `displayMode` property of the options object is ignored, and is instead taken from the `display` key of the corresponding entry in the `delimiters` key.