diff --git a/package-lock.json b/package-lock.json index 2637b244f6..eda97ec61d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@babel/runtime": "^7.26.10", "complex.js": "^2.2.5", "decimal.js": "^10.4.3", - "escape-latex": "^1.2.0", "fraction.js": "^5.2.1", "javascript-natural-sort": "^0.7.1", "seedrandom": "^3.0.5", @@ -117,6 +116,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2302,6 +2302,7 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -2366,6 +2367,7 @@ "integrity": "sha512-ixiWrCSRi33uqBMRuICcKECW7rtgY43TbsHDpM2XK7lXispd48opW+0IXrBVxv9NMhaz/Ue9kyj6r3NTVyXm8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -2415,6 +2417,7 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -2828,6 +2831,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3812,6 +3816,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5236,12 +5241,6 @@ "dev": true, "license": "MIT" }, - "node_modules/escape-latex": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", - "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -5262,6 +5261,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5347,6 +5347,7 @@ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5462,6 +5463,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5577,6 +5579,7 @@ "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "builtins": "^5.0.1", @@ -5690,6 +5693,7 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -8366,6 +8370,7 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -10324,6 +10329,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11018,6 +11024,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12079,6 +12086,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12420,6 +12428,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12994,6 +13003,7 @@ "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/package.json b/package.json index 07653a8be0..293dace9a0 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "@babel/runtime": "^7.26.10", "complex.js": "^2.2.5", "decimal.js": "^10.4.3", - "escape-latex": "^1.2.0", "fraction.js": "^5.2.1", "javascript-natural-sort": "^0.7.1", "seedrandom": "^3.0.5", diff --git a/src/utils/escapeLatex.js b/src/utils/escapeLatex.js new file mode 100644 index 0000000000..f1b8c2620e --- /dev/null +++ b/src/utils/escapeLatex.js @@ -0,0 +1,61 @@ +// Inline ESM replacement for the 'escape-latex' package (v1.2.0) +// Original: https://github.com/dangmai/escape-latex (MIT license) + +// Map the characters to escape to their escaped values. The list is derived +// from http://www.cespedes.org/blog/85/how-to-escape-latex-special-characters +const defaultEscapes = { + '{': '\\{', + '}': '\\}', + '\\': '\\textbackslash{}', + '#': '\\#', + $: '\\$', + '%': '\\%', + '&': '\\&', + '^': '\\textasciicircum{}', + _: '\\_', + '~': '\\textasciitilde{}' +} + +const formatEscapes = { + '\u2013': '\\--', + '\u2014': '\\---', + ' ': '~', + '\t': '\\qquad{}', + '\r\n': '\\newline{}', + '\n': '\\newline{}' +} + +/** + * Escape a string to be used in LaTeX documents. + * @param {string} str the string to be escaped. + * @param {boolean} params.preserveFormatting whether formatting escapes should + * be performed (default: false). + * @return {string} the escaped string, ready to be used in LaTeX. + */ +export function escapeLatex (str, { preserveFormatting = false } = {}) { + const s = String(str) + const escapes = preserveFormatting + ? { ...defaultEscapes, ...formatEscapes } + : defaultEscapes + const escapeKeys = Object.keys(escapes) + const parts = [] + let i = 0 + + while (i < s.length) { + let matched = false + for (let k = 0; k < escapeKeys.length; k++) { + const key = escapeKeys[k] + if (s.startsWith(key, i)) { + parts.push(escapes[key]) + i += key.length + matched = true + break + } + } + if (!matched) { + parts.push(s[i++]) + } + } + + return parts.join('') +} diff --git a/src/utils/latex.js b/src/utils/latex.js index 6ae2dc3b43..6dcc9bdf7d 100644 --- a/src/utils/latex.js +++ b/src/utils/latex.js @@ -1,6 +1,6 @@ /* eslint no-template-curly-in-string: "off" */ -import escapeLatexLib from 'escape-latex' +import { escapeLatex as escapeLatexLib } from './escapeLatex.js' import { hasOwnProperty } from './object.js' export const latexSymbols = { diff --git a/test/unit-tests/utils/escapeLatex.test.js b/test/unit-tests/utils/escapeLatex.test.js new file mode 100644 index 0000000000..b3f3873f29 --- /dev/null +++ b/test/unit-tests/utils/escapeLatex.test.js @@ -0,0 +1,159 @@ +import assert from 'assert' +import { escapeLatex } from '../../../src/utils/escapeLatex.js' + +describe('escapeLatex', function () { + it('should escape empty string correctly', function () { + assert.strictEqual(escapeLatex(''), '') + }) + + it('should escape casted string correctly', function () { + assert.strictEqual(escapeLatex(1), '1') + }) + + it('should escape # correctly', function () { + assert.strictEqual( + escapeLatex('Hashtag #yolo is all the rage these days #twitter'), + 'Hashtag \\#yolo is all the rage these days \\#twitter' + ) + }) + + it('should escape $ correctly', function () { + assert.strictEqual( + escapeLatex('$2 is greater than $1'), + '\\$2 is greater than \\$1' + ) + }) + + it('should escape % correctly', function () { + assert.strictEqual( + escapeLatex('100% is 20% point greater than 80%'), + '100\\% is 20\\% point greater than 80\\%' + ) + }) + + it('should escape & correctly', function () { + assert.strictEqual( + escapeLatex('Me & you & a dog named Boo'), + 'Me \\& you \\& a dog named Boo' + ) + }) + + it('should escape backslash correctly', function () { + assert.strictEqual( + escapeLatex('C:\\ is a good place to format'), + 'C:\\textbackslash{} is a good place to format' + ) + }) + + it('should escape { correctly', function () { + assert.strictEqual( + escapeLatex('This { does not have an matching bracket'), + 'This \\{ does not have an matching bracket' + ) + }) + + it('should escape } correctly', function () { + assert.strictEqual( + escapeLatex('There is no opening bracket for this }'), + 'There is no opening bracket for this \\}' + ) + }) + + it('should escape ^ correctly', function () { + assert.strictEqual( + escapeLatex('2^2^2^2 = 256'), + '2\\textasciicircum{}2\\textasciicircum{}2\\textasciicircum{}2 = 256' + ) + }) + + it('should escape _ correctly', function () { + assert.strictEqual( + escapeLatex('_ is a shortcut to Underscore, e.g., _.each()'), + '\\_ is a shortcut to Underscore, e.g., \\_.each()' + ) + }) + + it('should escape ~ correctly', function () { + assert.strictEqual( + escapeLatex('pi ~ 3.1416'), + 'pi \\textasciitilde{} 3.1416' + ) + }) + + it('should escape *nix newline correctly', function () { + assert.strictEqual( + escapeLatex('\n\n', { preserveFormatting: true }), + '\\newline{}\\newline{}' + ) + }) + + it('should escape Windows newline correctly', function () { + assert.strictEqual( + escapeLatex('\r\n\r\n', { preserveFormatting: true }), + '\\newline{}\\newline{}' + ) + }) + + it('should escape mixed newlines correctly', function () { + assert.strictEqual( + escapeLatex('\r\n\n\n\r\n', { preserveFormatting: true }), + '\\newline{}\\newline{}\\newline{}\\newline{}' + ) + }) + + it('should escape \u2013 (en-dash) correctly', function () { + assert.strictEqual( + escapeLatex('\u2013', { preserveFormatting: true }), + '\\--' + ) + }) + + it('should escape \u2014 (em-dash) correctly', function () { + assert.strictEqual( + escapeLatex('\u2014', { preserveFormatting: true }), + '\\---' + ) + }) + + it('should escape spaces correctly', function () { + assert.strictEqual( + escapeLatex('Look ma, multiple spaces', { preserveFormatting: true }), + 'Look~ma,~~multiple~spaces' + ) + }) + + it('should escape tabs correctly', function () { + assert.strictEqual( + escapeLatex('\t\t', { preserveFormatting: true }), + '\\qquad{}\\qquad{}' + ) + }) + + it('should not preserve formatting by default', function () { + assert.strictEqual( + escapeLatex('en dash \u2013 is cool'), + 'en dash \u2013 is cool' + ) + }) + + it('should not escape - (hyphen)', function () { + assert.strictEqual( + escapeLatex('hyphen - is the best'), + 'hyphen - is the best' + ) + }) + + it('stack overflow test', function () { + const numChars = 100000 + const originalStr = Array(numChars).join('\\') + const escapedStr = Array(numChars).join('\\textbackslash{}') + assert.strictEqual(escapeLatex(originalStr), escapedStr) + }) + + it('composite test 1', function () { + assert.strictEqual( + escapeLatex('These {} should be escaped, as well as this \\ character'), + 'These \\{\\} should be escaped, as well as this \\textbackslash{} character' + ) + }) +})