diff --git a/assets/css/latex2js.css b/assets/css/latex2js.css index dc0d4f1..90b9a8b 100644 --- a/assets/css/latex2js.css +++ b/assets/css/latex2js.css @@ -7,9 +7,19 @@ svg { -webkit-tap-highlight-color: rgba(0, 0, 0, 0); } +/* + * The gap TeX calls \parskip: the space between paragraphs, and between a + * paragraph and a figure. One value, set once, because that is what it is in + * TeX — a document-level style, not something an author adjusts by pressing + * return more times. + */ +:root { + --latex2js-parskip: 1.6em; +} + .pspicture { position: relative; - margin: auto; + margin: var(--latex2js-parskip) auto; } .enumerate { @@ -27,6 +37,63 @@ p.quotation { font-size: 10pt; } +/* End-of-proof tombstone, set flush right as amsthm places it. */ +span.qed { + display: block; + text-align: right; +} + +/* + * Theorem-like environments, set the way LaTeX sets them: the label runs into + * the statement as part of the sentence rather than sitting above it as a + * heading, and the body of a theorem is italic while a remark or a proof is + * not. The markup keeps a real heading element, so the structure survives for + * anything reading the document rather than looking at it. + */ +.theorem-env { + margin: 1em 0; +} + +h4.theorem-head { + display: inline; + margin: 0; + font-size: inherit; + font-style: normal; + font-weight: bold; +} + +h4.theorem-head::after { + content: '.'; +} + +.theorem-env--theorem, +.theorem-env--lemma, +.theorem-env--corollary, +.theorem-env--proposition, +.theorem-env--definition, +.theorem-env--axiom, +.theorem-env--claim { + font-style: italic; +} + +/* amsthm leaves these upright even though they are numbered alongside. */ +.theorem-env--remark, +.theorem-env--note, +.theorem-env--example, +.theorem-env--exercise, +.theorem-env--question, +.theorem-env--problem, +.theorem-env--solution, +.theorem-env--proof { + font-style: normal; +} + +/* The proof label is italic where a theorem label is bold. */ +.theorem-env--proof > h4.theorem-head { + font-weight: normal; + font-style: italic; +} + .nicebox { margin-top: 20px; min-height: 20px; @@ -59,6 +126,7 @@ p.quotation { padding-right: 20px; padding-left: 20px; } + .latex-container { width: auto; } @@ -73,3 +141,19 @@ p.quotation { pre { overflow: auto; } +/* + * Paragraph spacing. A run of blank lines is one \par however long it is, so + * the gap belongs to the document rather than to how many times the author hit + * return. Setting it here is what makes it adjustable at all: it used to be + * one
per blank line, which no stylesheet could reach. + * + * Bottom margin only, so the value is the gap. Adjacent margins would collapse + * to the larger of the two and the arithmetic would stop being obvious. + */ +.math > p.para { + margin: 0 0 var(--latex2js-parskip); +} + +.math > p.para:last-child { + margin-bottom: 0; +} diff --git a/assets/js/latex2html5.bundle.js b/assets/js/latex2html5.bundle.js index 8e1791b..6475470 100644 --- a/assets/js/latex2html5.bundle.js +++ b/assets/js/latex2html5.bundle.js @@ -22,6 +22,51 @@ function render(that) { },{}],2:[function(require,module,exports){ "use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = render; +function itemizeLine(line) { + var m = line.match(/\\item (.*)/); + if (m) + return '
  • ' + m[1] + '
  • '; + return line; +} +function descriptionLine(line) { + var m = line.match(/\\item\[([^\]]*)\]\s*(.*)/); + if (m) + return '
    ' + m[1] + '
    ' + m[2] + '
    '; + return itemizeLine(line); +} +/** + * Renders enumerate / itemize / description lists from \item lines. + */ +function render(that) { + const type = that.type || 'enumerate'; + const convert = type === 'description' ? descriptionLine : itemizeLine; + const lines = that.lines.map(convert).join('\n'); + let el; + if (type === 'enumerate') { + const ol = document.createElement('ol'); + ol.className = 'math enumerate'; + ol.innerHTML = lines; + el = ol; + } + else if (type === 'description') { + const dl = document.createElement('dl'); + dl.className = 'math description'; + dl.innerHTML = lines; + el = dl; + } + else { + const ul = document.createElement('ul'); + ul.className = 'math itemize'; + ul.innerHTML = lines; + el = ul; + } + return el; +} + +},{}],3:[function(require,module,exports){ +"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; @@ -37,18 +82,23 @@ function render(_that) { return div; } -},{"@latex2js/macros":14}],3:[function(require,module,exports){ +},{"@latex2js/macros":18}],4:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = render; +/** + * A block, not an inline span: the parser now emits real paragraphs, and a + * `

    ` inside a `` is invalid nesting that a browser silently hoists + * out, taking the text with it. + */ function render(that) { - const span = document.createElement('span'); - span.className = 'math'; - span.innerHTML = that.lines.join('\n'); - return span; + const div = document.createElement('div'); + div.className = 'math'; + div.innerHTML = that.lines.join('\n'); + return div; } -},{}],4:[function(require,module,exports){ +},{}],5:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = render; @@ -59,7 +109,7 @@ function render(that) { return span; } -},{}],5:[function(require,module,exports){ +},{}],6:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = render; @@ -91,6 +141,16 @@ function render(that) { if (!env.variables) env.variables = {}; env.variables[variable] = val; + // Re-render through the incremental path so plots stay in document + // order; removing the .psplot nodes and re-appending them put them at + // the end of the SVG, on top of every later shape. The graph keys the + // re-render on the variable that moved. + const redraw = that.redraw; + if (typeof redraw === 'function') { + redraw({ changed: [variable] }); + return; + } + // Fallback for a psgraph that predates the incremental renderer. svgEl.selectAll('.psplot').remove(); Object.entries(plot).forEach(([k, plotData]) => { if (k.match(/psplot/)) { @@ -121,7 +181,7 @@ function render(that) { return div; } -},{"@latex2js/pstricks":16,"@latex2js/utils":20}],6:[function(require,module,exports){ +},{"@latex2js/pstricks":20,"@latex2js/utils":25}],7:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = render; @@ -132,30 +192,33 @@ function render(that) { return pre; } -},{}],7:[function(require,module,exports){ +},{}],8:[function(require,module,exports){ "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.init = exports.macros = exports.math = exports.verbatim = exports.enumerate = exports.nicebox = exports.pspicture = void 0; +exports.init = exports.DEFAULT_CONFIG = exports.macros = exports.math = exports.verbatim = exports.list = exports.enumerate = exports.nicebox = exports.pspicture = void 0; exports.default = render; const latex2js_1 = __importDefault(require("latex2js")); const mathjaxjs_1 = require("mathjaxjs"); +Object.defineProperty(exports, "DEFAULT_CONFIG", { enumerable: true, get: function () { return mathjaxjs_1.DEFAULT_CONFIG; } }); const pspicture_js_1 = __importDefault(require("./components/pspicture.js")); exports.pspicture = pspicture_js_1.default; const nicebox_js_1 = __importDefault(require("./components/nicebox.js")); exports.nicebox = nicebox_js_1.default; const enumerate_js_1 = __importDefault(require("./components/enumerate.js")); exports.enumerate = enumerate_js_1.default; +const list_js_1 = __importDefault(require("./components/list.js")); +exports.list = list_js_1.default; const verbatim_js_1 = __importDefault(require("./components/verbatim.js")); exports.verbatim = verbatim_js_1.default; const math_js_1 = __importDefault(require("./components/math.js")); exports.math = math_js_1.default; const macros_1 = __importDefault(require("./components/macros")); exports.macros = macros_1.default; -const ELEMENTS = { pspicture: pspicture_js_1.default, nicebox: nicebox_js_1.default, enumerate: enumerate_js_1.default, verbatim: verbatim_js_1.default, math: math_js_1.default, macros: macros_1.default }; -function render(tex, resolve) { +const ELEMENTS = { pspicture: pspicture_js_1.default, nicebox: nicebox_js_1.default, enumerate: enumerate_js_1.default, itemize: list_js_1.default, description: list_js_1.default, verbatim: verbatim_js_1.default, math: math_js_1.default, macros: macros_1.default }; +function render(tex, resolve, config) { const done = () => { const latex = new latex2js_1.default(); const parsed = latex.parse(tex); @@ -174,21 +237,1469 @@ function render(tex, resolve) { if ((0, mathjaxjs_1.getMathJax)()) { return done(); } - (0, mathjaxjs_1.loadMathJax)(done); + (0, mathjaxjs_1.loadMathJax)(done, config); } -const init = () => { - (0, mathjaxjs_1.loadMathJax)(); +const init = (config) => { + (0, mathjaxjs_1.loadMathJax)(undefined, config); document.querySelectorAll('script[type="text/latex"]').forEach((el) => { render(el.innerHTML, (div) => { if (el.parentNode) { el.parentNode.insertBefore(div, el.nextSibling); } - }); + }, config); }); }; exports.init = init; -},{"./components/enumerate.js":1,"./components/macros":2,"./components/math.js":3,"./components/nicebox.js":4,"./components/pspicture.js":5,"./components/verbatim.js":6,"latex2js":8,"mathjaxjs":15}],8:[function(require,module,exports){ +},{"./components/enumerate.js":1,"./components/list.js":2,"./components/macros":3,"./components/math.js":4,"./components/nicebox.js":5,"./components/pspicture.js":6,"./components/verbatim.js":7,"latex2js":10,"mathjaxjs":19}],9:[function(require,module,exports){ +// @generated by Peggy 5.1.0. +// +// https://peggyjs.org/ + +"use strict"; + +class peg$SyntaxError extends SyntaxError { + constructor(message, expected, found, location) { + super(message); + this.expected = expected; + this.found = found; + this.location = location; + this.name = "SyntaxError"; + } + + format(sources) { + let str = "Error: " + this.message; + if (this.location) { + let src = null; + const st = sources.find(s => s.source === this.location.source); + if (st) { + src = st.text.split(/\r\n|\n|\r/g); + } + const s = this.location.start; + const offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + const loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + const e = this.location.end; + const filler = "".padEnd(offset_s.line.toString().length, " "); + const line = src[s.line - 1]; + const last = s.line === e.line ? e.column : line.length + 1; + const hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + "".padEnd(s.column - 1, " ") + + "".padEnd(hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; + } + + static buildMessage(expected, found) { + function hex(ch) { + return ch.codePointAt(0).toString(16).toUpperCase(); + } + + const nonPrintable = Object.prototype.hasOwnProperty.call(RegExp.prototype, "unicode") + ? new RegExp("[\\p{C}\\p{Mn}\\p{Mc}]", "gu") + : null; + function unicodeEscape(s) { + if (nonPrintable) { + return s.replace(nonPrintable, ch => "\\u{" + hex(ch) + "}"); + } + return s; + } + + function literalEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } + + function classEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } + + const DESCRIBE_EXPECTATION_FNS = { + literal(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, + + class(expectation) { + const escapedParts = expectation.parts.map( + part => (Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part)) + ); + + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]" + (expectation.unicode ? "u" : ""); + }, + + any() { + return "any character"; + }, + + end() { + return "end of input"; + }, + + other(expectation) { + return expectation.description; + }, + }; + + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } + + function describeExpected(expected) { + const descriptions = expected.map(describeExpectation); + descriptions.sort(); + + if (descriptions.length > 0) { + let j = 1; + for (let i = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } + + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; + } + } + + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + } + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; + } +} + +function peg$parse(input, options) { + options = options !== undefined ? options : {}; + + const peg$FAILED = {}; + const peg$source = options.grammarSource; + + const peg$startRuleFunctions = { + Document: peg$parseDocument, + }; + let peg$startRuleFunction = peg$parseDocument; + + const peg$c0 = "\\begin{"; + const peg$c1 = "verbatim"; + const peg$c2 = "print"; + const peg$c3 = "}"; + const peg$c4 = "\\end{"; + const peg$c5 = "\\"; + const peg$c6 = "begin{"; + const peg$c7 = "end{"; + const peg$c8 = "%"; + const peg$c9 = "\r\n"; + + const peg$r0 = /^[a-zA-Z*]/; + const peg$r1 = /^[a-zA-Z@]/; + const peg$r2 = /^[([{]/; + const peg$r3 = /^[)\]}]/; + const peg$r4 = /^[\n\r]/; + const peg$r5 = /^[ \t]/; + + const peg$e0 = peg$anyExpectation(); + const peg$e1 = peg$literalExpectation("\\begin{", false); + const peg$e2 = peg$literalExpectation("verbatim", false); + const peg$e3 = peg$literalExpectation("print", false); + const peg$e4 = peg$literalExpectation("}", false); + const peg$e5 = peg$literalExpectation("\\end{", false); + const peg$e6 = peg$classExpectation([["a", "z"], ["A", "Z"], "*"], false, false, false); + const peg$e7 = peg$literalExpectation("\\", false); + const peg$e8 = peg$literalExpectation("begin{", false); + const peg$e9 = peg$literalExpectation("end{", false); + const peg$e10 = peg$classExpectation([["a", "z"], ["A", "Z"], "@"], false, false, false); + const peg$e11 = peg$literalExpectation("%", false); + const peg$e12 = peg$classExpectation(["(", "[", "{"], false, false, false); + const peg$e13 = peg$classExpectation([")", "]", "}"], false, false, false); + const peg$e14 = peg$literalExpectation("\r\n", false); + const peg$e15 = peg$classExpectation(["\n", "\r"], false, false, false); + const peg$e16 = peg$classExpectation([" ", "\t"], false, false, false); + + function peg$f0(segs) { return segs; } + function peg$f1(e) { return { kind: 'strayEnd', name: e.name, raw: e.raw, loc: loc() }; } + function peg$f2(start, content, end) { + return { + kind: 'env', + name: start.name, + verbatim: true, + begin: start, + end: { name: start.name, raw: '\\end{' + end + '}', loc: loc() }, + content: [{ + kind: 'verbatim', + text: content.map((pair) => pair[1]).join('').replace(/\n$/, '') + }], + loc: loc() + }; + } + function peg$f3(n) { return { name: n, raw: '\\begin{' + n + '}', loc: loc() }; } + function peg$f4(n) { return n; } + function peg$f5(b, content, e) { + return { kind: 'env', name: b.name, verbatim: false, begin: b, end: e || null, content: content, loc: loc() }; + } + function peg$f6(name, tail) { + return { name: name, raw: '\\begin{' + name + '}' + tail, loc: loc() }; + } + function peg$f7(name) { + return { name: name, raw: '\\end{' + name + '}', loc: loc() }; + } + function peg$f8(chars) { return chars.join(''); } + function peg$f9(start, tail) { + depth = 0; + return { kind: 'command', name: start.name, raw: start.raw + tail, loc: loc() }; + } + function peg$f10(chars) { + return { name: chars.join(''), raw: '\\' + chars.join('') }; + } + function peg$f11(parts) { return parts.join(''); } + function peg$f12() { depth++; return text(); } + function peg$f13() { depth = Math.max(0, depth - 1); return text(); } + function peg$f14() { return depth === 0; } + function peg$f15(c) { return c; } + function peg$f16() { return depth > 0; } + function peg$f17(c) { return c; } + function peg$f18() { return ''; } + function peg$f19(parts, eol) { return { kind: 'line', parts: parts, hasEol: !!eol, loc: loc() }; } + function peg$f20(eol) { return { kind: 'line', parts: [], hasEol: true, loc: loc() }; } + function peg$f21(c) { return { kind: 'char', c: c, loc: loc() }; } + let peg$currPos = options.peg$currPos | 0; + let peg$savedPos = peg$currPos; + const peg$posDetailsCache = [{ line: 1, column: 1 }]; + let peg$maxFailPos = peg$currPos; + let peg$maxFailExpected = options.peg$maxFailExpected || []; + let peg$silentFails = options.peg$silentFails | 0; + + let peg$result; + + if (options.startRule) { + if (!(options.startRule in peg$startRuleFunctions)) { + throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); + } + + peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; + } + + function text() { + return input.substring(peg$savedPos, peg$currPos); + } + + function offset() { + return peg$savedPos; + } + + function range() { + return { + source: peg$source, + start: peg$savedPos, + end: peg$currPos, + }; + } + + function location() { + return peg$computeLocation(peg$savedPos, peg$currPos); + } + + function expected(description, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildStructuredError( + [peg$otherExpectation(description)], + input.substring(peg$savedPos, peg$currPos), + location + ); + } + + function error(message, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildSimpleError(message, location); + } + + function peg$getUnicode(pos = peg$currPos) { + const cp = input.codePointAt(pos); + if (cp === undefined) { + return ""; + } + return String.fromCodePoint(cp); + } + + function peg$literalExpectation(text, ignoreCase) { + return { type: "literal", text, ignoreCase }; + } + + function peg$classExpectation(parts, inverted, ignoreCase, unicode) { + return { type: "class", parts, inverted, ignoreCase, unicode }; + } + + function peg$anyExpectation() { + return { type: "any" }; + } + + function peg$endExpectation() { + return { type: "end" }; + } + + function peg$otherExpectation(description) { + return { type: "other", description }; + } + + function peg$computePosDetails(pos) { + let details = peg$posDetailsCache[pos]; + let p; + + if (details) { + return details; + } else { + if (pos >= peg$posDetailsCache.length) { + p = peg$posDetailsCache.length - 1; + } else { + p = pos; + while (!peg$posDetailsCache[--p]) {} + } + + details = peg$posDetailsCache[p]; + details = { + line: details.line, + column: details.column, + }; + + while (p < pos) { + if (input.charCodeAt(p) === 10) { + details.line++; + details.column = 1; + } else { + details.column++; + } + + p++; + } + + peg$posDetailsCache[pos] = details; + + return details; + } + } + + function peg$computeLocation(startPos, endPos, offset) { + const startPosDetails = peg$computePosDetails(startPos); + const endPosDetails = peg$computePosDetails(endPos); + + const res = { + source: peg$source, + start: { + offset: startPos, + line: startPosDetails.line, + column: startPosDetails.column, + }, + end: { + offset: endPos, + line: endPosDetails.line, + column: endPosDetails.column, + }, + }; + if (offset && peg$source && (typeof peg$source.offset === "function")) { + res.start = peg$source.offset(res.start); + res.end = peg$source.offset(res.end); + } + return res; + } + + function peg$fail(expected) { + if (peg$currPos < peg$maxFailPos) { return; } + + if (peg$currPos > peg$maxFailPos) { + peg$maxFailPos = peg$currPos; + peg$maxFailExpected = []; + } + + peg$maxFailExpected.push(expected); + } + + function peg$buildSimpleError(message, location) { + return new peg$SyntaxError(message, null, null, location); + } + + function peg$buildStructuredError(expected, found, location) { + return new peg$SyntaxError( + peg$SyntaxError.buildMessage(expected, found), + expected, + found, + location + ); + } + + function peg$parseDocument() { + let s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + s2 = peg$parseSegment(); + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = peg$parseSegment(); + } + peg$savedPos = s0; + s1 = peg$f0(s1); + s0 = s1; + + return s0; + } + + function peg$parseSegment() { + let s0; + + s0 = peg$parseEnv(); + if (s0 === peg$FAILED) { + s0 = peg$parseStrayEnd(); + if (s0 === peg$FAILED) { + s0 = peg$parseLine(); + } + } + + return s0; + } + + function peg$parseStrayEnd() { + let s0, s1; + + s0 = peg$currPos; + s1 = peg$parseEndTag(); + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f1(s1); + } + s0 = s1; + + return s0; + } + + function peg$parseEnv() { + let s0; + + s0 = peg$parseVerbatimEnv(); + if (s0 === peg$FAILED) { + s0 = peg$parseRegularEnv(); + } + + return s0; + } + + function peg$parseVerbatimEnv() { + let s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$parseBeginVerb(); + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + s4 = peg$currPos; + peg$silentFails++; + s5 = peg$parseEndVerb(); + peg$silentFails--; + if (s5 === peg$FAILED) { + s4 = undefined; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + if (s4 !== peg$FAILED) { + if (input.length > peg$currPos) { + s5 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$currPos; + peg$silentFails++; + s5 = peg$parseEndVerb(); + peg$silentFails--; + if (s5 === peg$FAILED) { + s4 = undefined; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + if (s4 !== peg$FAILED) { + if (input.length > peg$currPos) { + s5 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + s3 = peg$parseEndVerb(); + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f2(s1, s2, s3); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseBeginVerb() { + let s0, s1, s2, s3; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 7) === peg$c0) { + s1 = peg$c0; + peg$currPos += 7; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + if (s1 !== peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c1) { + s2 = peg$c1; + peg$currPos += 8; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s2 === peg$FAILED) { + if (input.substr(peg$currPos, 5) === peg$c2) { + s2 = peg$c2; + peg$currPos += 5; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + } + if (s2 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 125) { + s3 = peg$c3; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e4); } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f3(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseEndVerb() { + let s0, s1, s2, s3; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 5) === peg$c4) { + s1 = peg$c4; + peg$currPos += 5; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + if (s1 !== peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c1) { + s2 = peg$c1; + peg$currPos += 8; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s2 === peg$FAILED) { + if (input.substr(peg$currPos, 5) === peg$c2) { + s2 = peg$c2; + peg$currPos += 5; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + } + if (s2 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 125) { + s3 = peg$c3; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e4); } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f4(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseRegularEnv() { + let s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$parseBeginTag(); + if (s1 !== peg$FAILED) { + s2 = peg$parse_(); + s3 = []; + s4 = peg$parseEnvContent(); + while (s4 !== peg$FAILED) { + s3.push(s4); + s4 = peg$parseEnvContent(); + } + s4 = peg$parse_(); + s5 = peg$parseEndTag(); + if (s5 === peg$FAILED) { + s5 = null; + } + peg$savedPos = s0; + s0 = peg$f5(s1, s3, s5); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseBeginTag() { + let s0, s1, s2, s3, s4; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 7) === peg$c0) { + s1 = peg$c0; + peg$currPos += 7; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + if (s1 !== peg$FAILED) { + s2 = peg$parseEnvName(); + if (s2 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 125) { + s3 = peg$c3; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e4); } + } + if (s3 !== peg$FAILED) { + s4 = peg$parseTail(); + peg$savedPos = s0; + s0 = peg$f6(s2, s4); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseEndTag() { + let s0, s1, s2, s3; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 5) === peg$c4) { + s1 = peg$c4; + peg$currPos += 5; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + if (s1 !== peg$FAILED) { + s2 = peg$parseEnvName(); + if (s2 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 125) { + s3 = peg$c3; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e4); } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f7(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseEnvName() { + let s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + s2 = input.charAt(peg$currPos); + if (peg$r0.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e6); } + } + if (s2 !== peg$FAILED) { + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = input.charAt(peg$currPos); + if (peg$r0.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e6); } + } + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f8(s1); + } + s0 = s1; + + return s0; + } + + function peg$parseEnvContent() { + let s0; + + s0 = peg$parseEnv(); + if (s0 === peg$FAILED) { + s0 = peg$parseCommand(); + if (s0 === peg$FAILED) { + s0 = peg$parseLine(); + } + } + + return s0; + } + + function peg$parseCommand() { + let s0, s1, s2; + + s0 = peg$currPos; + s1 = peg$parseCommandStart(); + if (s1 !== peg$FAILED) { + s2 = peg$parseTail(); + peg$savedPos = s0; + s0 = peg$f9(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseCommandStart() { + let s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 92) { + s1 = peg$c5; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + if (input.substr(peg$currPos, 6) === peg$c6) { + s3 = peg$c6; + peg$currPos += 6; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e8); } + } + peg$silentFails--; + if (s3 === peg$FAILED) { + s2 = undefined; + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + s3 = peg$currPos; + peg$silentFails++; + if (input.substr(peg$currPos, 4) === peg$c7) { + s4 = peg$c7; + peg$currPos += 4; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e9); } + } + peg$silentFails--; + if (s4 === peg$FAILED) { + s3 = undefined; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + s4 = []; + s5 = input.charAt(peg$currPos); + if (peg$r1.test(s5)) { + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e10); } + } + if (s5 !== peg$FAILED) { + while (s5 !== peg$FAILED) { + s4.push(s5); + s5 = input.charAt(peg$currPos); + if (peg$r1.test(s5)) { + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e10); } + } + } + } else { + s4 = peg$FAILED; + } + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f10(s4); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseTail() { + let s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + s2 = peg$parseTailPart(); + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = peg$parseTailPart(); + } + peg$savedPos = s0; + s1 = peg$f11(s1); + s0 = s1; + + return s0; + } + + function peg$parseTailPart() { + let s0, s1, s2, s3, s4, s5, s6; + + s0 = peg$parseComment(); + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$parseOpen(); + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f12(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$parseClose(); + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f13(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + peg$savedPos = peg$currPos; + s1 = peg$f14(); + if (s1) { + s1 = undefined; + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parseEOL(); + peg$silentFails--; + if (s3 === peg$FAILED) { + s2 = undefined; + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + s3 = peg$currPos; + peg$silentFails++; + s4 = peg$parseCommandStart(); + peg$silentFails--; + if (s4 === peg$FAILED) { + s3 = undefined; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + s4 = peg$currPos; + peg$silentFails++; + s5 = peg$parseBeginStart(); + peg$silentFails--; + if (s5 === peg$FAILED) { + s4 = undefined; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + if (s4 !== peg$FAILED) { + s5 = peg$currPos; + peg$silentFails++; + s6 = peg$parseEndStart(); + peg$silentFails--; + if (s6 === peg$FAILED) { + s5 = undefined; + } else { + peg$currPos = s5; + s5 = peg$FAILED; + } + if (s5 !== peg$FAILED) { + if (input.length > peg$currPos) { + s6 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s6 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s6 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f15(s6); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + peg$savedPos = peg$currPos; + s1 = peg$f16(); + if (s1) { + s1 = undefined; + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + if (input.length > peg$currPos) { + s2 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f17(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } + } + } + } + + return s0; + } + + function peg$parseComment() { + let s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 37) { + s1 = peg$c8; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e11); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + s4 = peg$currPos; + peg$silentFails++; + s5 = peg$parseEOL(); + peg$silentFails--; + if (s5 === peg$FAILED) { + s4 = undefined; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + if (s4 !== peg$FAILED) { + if (input.length > peg$currPos) { + s5 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$currPos; + peg$silentFails++; + s5 = peg$parseEOL(); + peg$silentFails--; + if (s5 === peg$FAILED) { + s4 = undefined; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + if (s4 !== peg$FAILED) { + if (input.length > peg$currPos) { + s5 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + peg$savedPos = s0; + s0 = peg$f18(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseOpen() { + let s0; + + s0 = input.charAt(peg$currPos); + if (peg$r2.test(s0)) { + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e12); } + } + + return s0; + } + + function peg$parseClose() { + let s0; + + s0 = input.charAt(peg$currPos); + if (peg$r3.test(s0)) { + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e13); } + } + + return s0; + } + + function peg$parseBeginStart() { + let s0; + + if (input.substr(peg$currPos, 7) === peg$c0) { + s0 = peg$c0; + peg$currPos += 7; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + + return s0; + } + + function peg$parseEndStart() { + let s0; + + if (input.substr(peg$currPos, 5) === peg$c4) { + s0 = peg$c4; + peg$currPos += 5; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + + return s0; + } + + function peg$parseLine() { + let s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + s2 = peg$parseLinePart(); + if (s2 !== peg$FAILED) { + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = peg$parseLinePart(); + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + s2 = peg$parseEOL(); + if (s2 === peg$FAILED) { + s2 = null; + } + peg$savedPos = s0; + s0 = peg$f19(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$parseEOL(); + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f20(s1); + } + s0 = s1; + } + + return s0; + } + + function peg$parseLinePart() { + let s0, s1, s2, s3, s4; + + s0 = peg$parseComment(); + if (s0 === peg$FAILED) { + s0 = peg$parseCommand(); + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$currPos; + peg$silentFails++; + s2 = peg$parseBeginStart(); + peg$silentFails--; + if (s2 === peg$FAILED) { + s1 = undefined; + } else { + peg$currPos = s1; + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parseEndStart(); + peg$silentFails--; + if (s3 === peg$FAILED) { + s2 = undefined; + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + s3 = peg$currPos; + peg$silentFails++; + s4 = peg$parseEOL(); + peg$silentFails--; + if (s4 === peg$FAILED) { + s3 = undefined; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + if (input.length > peg$currPos) { + s4 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f21(s4); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } + } + + return s0; + } + + function peg$parseEOL() { + let s0; + + if (input.substr(peg$currPos, 2) === peg$c9) { + s0 = peg$c9; + peg$currPos += 2; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e14); } + } + if (s0 === peg$FAILED) { + s0 = input.charAt(peg$currPos); + if (peg$r4.test(s0)) { + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e15); } + } + } + + return s0; + } + + function peg$parse_() { + let s0, s1; + + s0 = []; + s1 = input.charAt(peg$currPos); + if (peg$r5.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e16); } + } + while (s1 !== peg$FAILED) { + s0.push(s1); + s1 = input.charAt(peg$currPos); + if (peg$r5.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e16); } + } + } + + return s0; + } + + + let depth = 0; + + function loc() { + const l = location(); + return { line: l.start.line, column: l.start.column }; + } + + peg$result = peg$startRuleFunction(); + + const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length); + function peg$throw() { + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? peg$getUnicode(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); + } + if (options.peg$library) { + return /** @type {any} */ ({ + peg$result, + peg$currPos, + peg$FAILED, + peg$maxFailExpected, + peg$maxFailPos, + peg$success, + peg$throw: peg$success ? undefined : peg$throw, + }); + } + if (peg$success) { + return peg$result; + } else { + peg$throw(); + } +} + +module.exports = { + StartRules: ["Document"], + SyntaxError: peg$SyntaxError, + parse: peg$parse, +}; + +},{}],10:[function(require,module,exports){ "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; @@ -202,6 +1713,16 @@ const ignore_1 = __importDefault(require("./lib/ignore")); const parser_1 = __importDefault(require("./lib/parser")); class LaTeX2HTML5 { constructor(Text = text_1.default, Headers = headers_1.default, Environments = environments_1.default, Ignore = ignore_1.default, PSTricks = pstricks_1.pstricks, Views = {}) { + this.lastDiagnostics = []; + /** + * Which language documents are read as, when they do not declare one. + * + * An application that only ever renders LaTeX2JS content sets this once + * rather than editing every document; a document's own + * `\psset{dialect=...}` still overrides it. Defaults to `pstricks`, so an + * undeclared extension is reported rather than passing unnoticed. + */ + this.dialect = 'pstricks'; this.Text = Text; this.Headers = Headers; this.Environments = Environments; @@ -251,6 +1772,7 @@ class LaTeX2HTML5 { parse(text) { const parser = new parser_1.default(this); const parsed = parser.parse(text); + this.lastDiagnostics = parser.diagnostics; parsed.forEach((element) => { if (!element.hasOwnProperty('type')) { throw new Error('no type!'); @@ -262,62 +1784,325 @@ class LaTeX2HTML5 { } exports.default = LaTeX2HTML5; -},{"./lib/environments":9,"./lib/headers":10,"./lib/ignore":11,"./lib/parser":12,"./lib/text":13,"@latex2js/pstricks":16}],9:[function(require,module,exports){ +},{"./lib/environments":13,"./lib/headers":14,"./lib/ignore":15,"./lib/parser":16,"./lib/text":17,"@latex2js/pstricks":20}],11:[function(require,module,exports){ +"use strict"; +/** + * Document counters for sectioning and theorem-like environments. + * + * Numbering is what makes a document cross-referenceable — "see Theorem 3" only + * works if theorems are numbered — and LaTeX2JS emitted none, so a rendered + * document could not be cited from or navigated the way its printed form can. + * + * Equations are deliberately absent: those stay with MathJax, which already + * numbers AMS environments and resolves `\label`/`\ref` against them. Splitting + * that between two systems would give a document two disagreeing sets of + * numbers. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NUMBERED_ENVIRONMENTS = exports.Counters = exports.SECTION_LEVELS = void 0; +/** The sectioning levels, outermost first. */ +exports.SECTION_LEVELS = ['section', 'subsection', 'subsubsection']; +/** + * Counters for one document. + * + * A parser instance is reused across documents, so these are reset at the start + * of every parse; otherwise a second document would continue the first one's + * numbering. + */ +class Counters { + constructor() { + this.sections = exports.SECTION_LEVELS.map(() => 0); + this.environments = {}; + } + /** Starts a fresh document. */ + reset() { + this.sections = exports.SECTION_LEVELS.map(() => 0); + this.environments = {}; + } + /** + * Advances a sectioning level and returns its number. + * + * Deeper levels reset, so a new section restarts subsection numbering. A + * subsection appearing before any section reports `0.1`, which is what LaTeX + * does rather than an error — the document is odd, not broken. + * + * @param level - which sectioning level is starting + * @returns the dotted number, such as `2.3` + */ + section(level) { + const depth = exports.SECTION_LEVELS.indexOf(level); + this.sections[depth] += 1; + for (let i = depth + 1; i < this.sections.length; i++) + this.sections[i] = 0; + return this.sections.slice(0, depth + 1).join('.'); + } + /** + * Advances the counter for a theorem-like environment and returns its number. + * + * Each kind counts independently — Theorem 1 and Lemma 1 can both exist — + * which is what a plain `\newtheorem{name}{Name}` declaration gives. + * + * @param name - the environment name, such as `theorem` + * @returns the next number for that kind + */ + environment(name) { + this.environments[name] = (this.environments[name] ?? 0) + 1; + return this.environments[name]; + } +} +exports.Counters = Counters; +/** Environments that carry a number. `proof` and quotations deliberately do not. */ +exports.NUMBERED_ENVIRONMENTS = [ + 'theorem', + 'lemma', + 'corollary', + 'proposition', + 'definition', + 'axiom', + 'claim', + 'example', + 'remark', + 'note', + 'exercise', + 'question', + 'problem', + 'solution', +]; + +},{}],12:[function(require,module,exports){ "use strict"; +/** + * Which constructs belong to the LaTeX2JS dialect rather than to PSTricks. + * + * LaTeX2JS accepts a superset of PSTricks: some of it is the deliberate reason + * this project exists (the interactive macros), some of it is convenience + * (infix plot bodies, CSS colour names), and some is a genuine semantic fork + * (`log` is natural log here, base 10 there). Until a document could declare + * which language it was written in, none of that was distinguishable from a + * defect — by a reader, a test, or the author. + * + * A document declares `\psset{dialect=latex2js}`, or the embedding application + * sets it once. Anything left undeclared is read as PSTricks and reported. + * + * Reporting is all this does. The dialect changes a small number of documented + * semantics (see `Dialect` in @latex2js/settings); it does not switch the + * renderer, and nothing here refuses to draw. + */ Object.defineProperty(exports, "__esModule", { value: true }); -const environments = ['pspicture', 'verbatim', 'enumerate', 'print', 'nicebox']; +exports.dialectUses = dialectUses; +/** Colour names the browser knows and xcolor does not. */ +const CSS_ONLY_COLORS = ['lightblue', 'lightgray', 'lightgrey', 'lightgreen', 'darkblue', 'darkgreen', 'pink', 'gold', 'silver', 'navy', 'teal', 'lime', 'aqua', 'fuchsia']; +/** Commands with no PSTricks counterpart at all. */ +const LATEX2JS_ONLY_COMMANDS = { + userline: 'draws a line the reader can drag; PSTricks has no interactive graphics', + uservariable: 'binds a value to the pointer position; PSTricks has no such binding', + slider: 'renders a control the reader can move; PSTricks has no such control', +}; +/** + * Reports the dialect constructs one parsed command uses. + * + * @param name - the command name, without its backslash + * @param raw - the command's source text + * @param options - its parsed options, or null when it has none + * @returns every construct the command relies on, empty when it is plain PSTricks + */ +function dialectUses(name, raw, options) { + const uses = []; + const only = LATEX2JS_ONLY_COMMANDS[name]; + if (only) + uses.push({ construct: `\\${name}`, detail: only }); + // A bare key is a syntax error to PSTricks, which expects `key=value`. + const bare = /\[([^\]]*)\]/.exec(raw); + if (bare) { + for (const part of bare[1].split(',')) { + const token = part.trim(); + if (token && !token.includes('=')) { + uses.push({ + construct: 'bare option flag', + detail: `\`${token}\` has no value; PSTricks requires \`${token}=true\``, + }); + } + } + } + if (options) { + for (const key of ['linecolor', 'fillcolor', 'hatchcolor', 'gridcolor']) { + const value = String(options[key] ?? '').trim().toLowerCase(); + if (CSS_ONLY_COLORS.indexOf(value) !== -1) { + uses.push({ + construct: 'CSS colour name', + detail: `\`${value}\` is a browser colour; xcolor does not define it`, + }); + } + } + if (options.plotpoints !== undefined && Number(options.plotpoints) === 1) { + uses.push({ + construct: 'plotpoints=1', + detail: 'PSTricks requires at least 2 samples', + }); + } + } + if (name === 'psplot') + uses.push(...plotBodyUses(raw, options)); + return uses; +} +/** + * Reports what a `\psplot` body relies on. + * + * The dialects have opposite defaults here: LaTeX2JS always reads the body as + * an infix expression, while PSTricks reads RPN PostScript unless told + * `algebraic=true`. A body is therefore worth reporting whenever the document + * has not asked for algebraic mode, whatever it contains. + */ +function plotBodyUses(raw, options) { + const uses = []; + const groups = raw.match(/\{([^{}]*)\}/g) || []; + const bodies = groups.map((g) => g.slice(1, -1)); + const declaredAlgebraic = String(options?.algebraic ?? '').toLowerCase() === 'true'; + if (!declaredAlgebraic) { + uses.push({ + construct: 'infix plot body', + detail: 'read as an infix expression; PSTricks reads RPN PostScript unless algebraic=true', + }); + } + const body = bodies[bodies.length - 1] ?? ''; + if (/\bpow\s*\(/.test(body)) { + uses.push({ construct: 'pow()', detail: 'PSTricks has no pow function, only the ^ operator' }); + } + if (/\blog\s*\(/.test(body)) { + uses.push({ construct: 'log()', detail: 'natural log here, base 10 in PSTricks' }); + } + // The two bounds precede the body; a non-numeric one is an expression over + // variables, which PSTricks cannot evaluate. + for (const bound of bodies.slice(0, -1)) { + if (bound.trim() && !/^-?[\d.]+$/.test(bound.trim())) { + uses.push({ + construct: 'variable plot bound', + detail: `\`${bound.trim()}\` is an expression; PSTricks needs a literal number`, + }); + } + } + return uses; +} + +},{}],13:[function(require,module,exports){ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const environments = ['pspicture', 'verbatim', 'enumerate', 'print', 'nicebox', 'itemize', 'description']; exports.default = environments; -},{}],10:[function(require,module,exports){ +},{}],14:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Functions = exports.Expressions = void 0; exports.Expressions = { bq: /\\begin\{quotation\}/, - claim: /\\begin\{claim\}/, - corollary: /\\begin\{corollary\}/, - definition: /\\begin\{definition\}/, - endclaim: /\\end\{claim\}/, - endcorallary: /\\end\{corallary\}/, - enddefinition: /\\end\{definition\}/, - endexample: /\\end\{example\}/, - endproblem: /\\end\{problem\}/, - endsolution: /\\end\{solution\}/, - endtheorem: /\\end\{theorem\}/, + claim: /\\begin\{claim\*?\}/, + corollary: /\\begin\{corollary\*?\}/, + definition: /\\begin\{definition\*?\}/, + lemma: /\\begin\{lemma\*?\}/, + proposition: /\\begin\{proposition\*?\}/, + axiom: /\\begin\{axiom\*?\}/, + remark: /\\begin\{remark\*?\}/, + note: /\\begin\{note\*?\}/, + exercise: /\\begin\{exercise\*?\}/, + question: /\\begin\{question\*?\}/, + endclaim: /\\end\{claim\*?\}/, + endcorollary: /\\end\{corollary\*?\}/, + enddefinition: /\\end\{definition\*?\}/, + endexample: /\\end\{example\*?\}/, + endlemma: /\\end\{lemma\*?\}/, + endproposition: /\\end\{proposition\*?\}/, + endaxiom: /\\end\{axiom\*?\}/, + endremark: /\\end\{remark\*?\}/, + endnote: /\\end\{note\*?\}/, + endexercise: /\\end\{exercise\*?\}/, + endquestion: /\\end\{question\*?\}/, + endproblem: /\\end\{problem\*?\}/, + endsolution: /\\end\{solution\*?\}/, + endtheorem: /\\end\{theorem\*?\}/, eq: /\\end\{quotation\}/, - example: /\\begin\{example\}/, - problem: /\\begin\{problem\}/, + example: /\\begin\{example\*?\}/, + problem: /\\begin\{problem\*?\}/, proof: /\\begin\{proof\}/, qed: /\\end\{proof\}/, - solution: /\\begin\{solution\}/, - theorem: /\\begin\{theorem\}/ + solution: /\\begin\{solution\*?\}/, + theorem: /\\begin\{theorem\*?\}/ }; +/** + * Opens a theorem-like environment with a run-in heading. + * + * LaTeX sets these as `**Theorem 1.** statement` on one line, not as a heading + * with the statement beneath it — the label is part of the sentence. The + * wrapper lets CSS carry that, and lets the body take the italic that amsthm + * gives a theorem and withholds from a remark. + * + * The number comes from the parser through the receiver, so this registry + * carries no parser internals; a host that supplies none still renders the + * heading, just unnumbered. Each kind counts independently — Theorem 1 and + * Lemma 1 can both exist — which is what a plain \newtheorem gives. + * + * @param title - the label a reader sees, such as `Theorem` + * @param name - the environment name, for the counter and the body style + * @param parser - the receiver, when the caller supplied one + * @param match - the matched \begin, so a starred form can opt out + * @returns the opening markup, closed by the matching `end` entry + */ +function headed(title, name, parser, match) { + const raw = Array.isArray(match) ? String(match[0] ?? '') : String(match ?? ''); + const number = parser && typeof parser.environmentNumber === 'function' + ? parser.environmentNumber(name, raw) + : null; + const label = number === null || number === undefined ? '' : ' ' + number; + return ('

    ' + + '

    ' + title + label + '

    '); +} +/** Closes an environment opened by {@link headed}. */ +function closed() { + return '
    '; +} exports.Functions = { bq: () => '

    ', - claim: () => '

    Claim

    ', - corollary: () => '

    Corollary

    ', - definition: () => '

    Definition

    ', - endclaim: () => '', - endcorollary: () => '', - enddefinition: () => '', - endexample: () => '', - endproblem: () => '', - endsolution: () => '', - endtheorem: () => '', + claim(m) { return headed('Claim', 'claim', this, m); }, + corollary(m) { return headed('Corollary', 'corollary', this, m); }, + definition(m) { return headed('Definition', 'definition', this, m); }, + lemma(m) { return headed('Lemma', 'lemma', this, m); }, + proposition(m) { return headed('Proposition', 'proposition', this, m); }, + axiom(m) { return headed('Axiom', 'axiom', this, m); }, + remark(m) { return headed('Remark', 'remark', this, m); }, + note(m) { return headed('Note', 'note', this, m); }, + exercise(m) { return headed('Exercise', 'exercise', this, m); }, + question(m) { return headed('Question', 'question', this, m); }, + endclaim: () => closed(), + endcorollary: () => closed(), + enddefinition: () => closed(), + endexample: () => closed(), + endlemma: () => closed(), + endproposition: () => closed(), + endaxiom: () => closed(), + endremark: () => closed(), + endnote: () => closed(), + endexercise: () => closed(), + endquestion: () => closed(), + endproblem: () => closed(), + endsolution: () => closed(), + endtheorem: () => closed(), eq: () => '

    ', - example: () => '

    Example

    ', - problem: () => '

    Problem

    ', - proof: () => '

    Proof

    ', - qed: () => '$\\qed$', - solution: () => '

    Solution

    ', - theorem: () => '

    Theorem

    ' + example(m) { return headed('Example', 'example', this, m); }, + problem(m) { return headed('Problem', 'problem', this, m); }, + proof: () => '

    Proof

    ', + // amsthm closes a proof with an open square. Emitted as a character rather + // than as math: MathJax defines no \qed, so the previous `$\qed$` surfaced + // an "Undefined control sequence" box at the end of every proof. + qed: () => '
    ', + solution(m) { return headed('Solution', 'solution', this, m); }, + theorem(m) { return headed('Theorem', 'theorem', this, m); } }; exports.default = { Expressions: exports.Expressions, Functions: exports.Functions }; -},{}],11:[function(require,module,exports){ +},{}],15:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const ignore = [ @@ -343,9 +2128,226 @@ const ignore = [ ]; exports.default = ignore; -},{}],12:[function(require,module,exports){ +},{}],16:[function(require,module,exports){ "use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); Object.defineProperty(exports, "__esModule", { value: true }); +const pegParser = __importStar(require("../grammar/parser.js")); +const dialect_1 = require("./dialect"); +const settings_1 = require("@latex2js/settings"); +const utils_1 = require("@latex2js/utils"); +const counters_1 = require("./counters"); +/** + * Keys `\psset` may declare that are not style defaults for a shape. + * + * Units are consumed when the picture is set up and are already folded into + * every coordinate by the time a command is parsed; copying them onto the + * shape as well would apply them a second time. The dialect is a document + * property, not a drawing option. + */ +const PSSET_NON_STYLE = new Set(['unit', 'runit', 'xunit', 'yunit', 'dialect']); +/** + * `\definecolor{name}{model}{spec}`. + * + * A preamble declaration rather than content, so it is intercepted where + * \psset is: the grammar delivers a command inside the line that holds it, + * not as a node of its own, and anything not intercepted is rendered as text. + */ +const DEFINECOLOR_RE = /\\definecolor\s*\{([^}]*)\}\s*\{([^}]*)\}\s*\{([^}]*)\}/; +/** + * The style defaults out of a parsed `\psset`. + * + * Kept apart from `settings`, which is not purely psset state: the pspicture + * parse function is invoked with `settings` as its receiver and assigns the + * picture bounds onto it, so `settings` also carries x0, y0, x1, y1, w and h. + * Copying those onto a shape overwrites its computed geometry with the corner + * of the picture. + * + * @param declared - the result of parsing one \psset + * @returns only the keys that are defaults for a later command + */ +function pssetStyle(declared) { + const style = {}; + for (const [key, value] of Object.entries(declared || {})) { + if (PSSET_NON_STYLE.has(key)) + continue; + if (value === undefined) + continue; + style[key] = value; + } + return style; +} +/** + * Applies the `\psset` defaults in force where a command was written. + * + * A command's own brackets win, its psset defaults come next, and the + * hardcoded default in its parse function is only the last resort. That order + * cannot be expressed by assigning before or after `parseOptions`, because by + * the time the parse function returns there is no way to tell a hardcoded + * `linecolor: 'black'` from one the author wrote — so the inline options are + * re-read here, and a psset key is applied to anything the author left out. + * + * Without this, psset parsed and then discarded every style key: a picture + * opening with `\psset{linewidth=2pt,linestyle=dashed,fillstyle=solid}` drew + * plain thin outlines. + * + * @param data - the parsed command, mutated in place + * @param settings - the psset state at this command's position in the source + * @param raw - the command's source, read for its own bracket group + */ +function applyPsset(data, style, raw) { + if (!data || !style) + return; + const bracket = typeof raw === 'string' ? raw.match(/\[([^\]]*)\]/) : null; + const inline = bracket ? bracket[1].split(',').map((p) => p.split('=')[0].trim()) : []; + for (const [key, value] of Object.entries(style)) { + if (inline.indexOf(key) !== -1) + continue; + data[key] = value; + } +} +/** + * The environment a command's coordinates should be computed against, once the + * `\psset` above it has changed the units. + * + * A picture fixes its units when `\begin{pspicture}` is read, so a later + * `\psset{xunit=2}` inside it had no effect at all — `\psellipse(0,0)(1,1.5)` + * came out taller than wide where PSTricks draws it wider than tall. + * + * The picture's origin does not move when the units change: the box was laid + * out with the units in force at `\begin`, and only the coordinates written + * after the declaration are rescaled. `X(v)` is `(w - x1) * xunit + v * xunit`, + * so holding the first term at its original value while the second takes the + * new unit means solving for the `w` that keeps the offset — which is what the + * adjusted bounds below do. + * + * @param env - the picture environment, carrying the units from \begin + * @param units - the units in force where the command was written + * @returns `env` itself when nothing changed, else a rescaled copy + */ +function envForUnits(env, units) { + if (!env || !units) + return env; + const xunit = Number(units.xunit); + const yunit = Number(units.yunit); + const sameX = !isFinite(xunit) || xunit === env.xunit; + const sameY = !isFinite(yunit) || yunit === env.yunit; + const sameR = !isFinite(Number(units.runit)) || Number(units.runit) === env.runit; + if (sameX && sameY && sameR) + return env; + const scaled = { ...env }; + // A radius follows runit, and needs no origin correction: it is a length, + // not a position. + const runit = Number(units.runit); + if (isFinite(runit) && runit > 0) + scaled.runit = runit; + if (!sameX && xunit > 0) { + const originX = (env.w - env.x1) * env.xunit; + scaled.xunit = xunit; + scaled.w = originX / xunit + env.x1; + } + if (!sameY && yunit > 0) { + const originY = env.y1 * env.yunit; + scaled.yunit = yunit; + scaled.y1 = originY / yunit; + } + return scaled; +} +/** + * The brace group of an `\rput`, matched by depth rather than by regex. + * + * The rput expression ends in `\{(.*)\}`, which is greedy and brace-blind: on + * `\rput(0,0){\pscircle(0,0){0.8}}` it captures through the inner group and + * leaves the tail behind, which is how `0.8}` ended up rendered as a label. + * + * @param raw - the command's source + * @returns the contents of the outermost brace group, empty when unbalanced + */ +function braceGroup(raw) { + if (typeof raw !== 'string') + return ''; + const start = raw.indexOf('{'); + if (start === -1) + return ''; + let depth = 0; + for (let i = start; i < raw.length; i++) { + if (raw[i] === '{') + depth++; + else if (raw[i] === '}' && --depth === 0) + return raw.slice(start + 1, i); + } + return ''; +} +/** + * Names the numeric fields of a parsed command that came out non-finite. + * + * `X` and `Y` return NaN for a coordinate they cannot compute rather than + * inventing one at the origin, so this is where that shows up: a command whose + * geometry is unusable is reported against its own source location instead of + * drawing a plausible shape in the wrong place. + * + * @param value - a parsed command's data + * @returns the paths of the offending fields, empty when everything is usable + */ +function nonFiniteFields(value, path = '', depth = 0) { + if (depth > 4 || value === null || value === undefined) + return []; + if (typeof value === 'number') + return isFinite(value) ? [] : [path || 'value']; + if (Array.isArray(value)) { + return value.flatMap((v, i) => typeof v === 'number' && !isFinite(v) ? [`${path}[${i}]`] : nonFiniteFields(v, `${path}[${i}]`, depth + 1)); + } + if (typeof value !== 'object') + return []; + // `global` is the shared environment, not this command's own geometry. + return Object.entries(value) + .filter(([k]) => k !== 'global' && k !== 'env') + .flatMap(([k, v]) => nonFiniteFields(v, path ? `${path}.${k}` : k, depth + 1)); +} +/** + * Parser: turns a LaTeX-ish document into the flat environment objects the + * components consume ({type, lines, env, plot}) — but driven by the Peggy + * grammar in src/grammar instead of per-line regular expressions. + * + * The grammar tokenizes structure (balanced environments, commands with args, + * comments, verbatim). This class interprets that tree using the registries + * (Text / Headers / Ignore / PSTricks / Delimiters), so the runtime extension + * API (addEnvironment / addText / addHeaders) keeps working. It also collects + * diagnostics (unclosed environments, unknown commands, syntax errors) that + * were previously silent. + */ class Parser { constructor(LaTeX2JS) { this.Ignore = LaTeX2JS.Ignore; @@ -359,30 +2361,308 @@ class Parser { '', 'units=1cm,linecolor=black,linestyle=solid,fillstyle=none' ]); + this.style = pssetStyle(this.settings); + this.diagnostics = []; + // The embedding application can declare the dialect once for every document + // it renders; a document's own \psset overrides it. + this.dialect = (0, settings_1.normalizeDialect)(LaTeX2JS.dialect) ?? 'pstricks'; + this.counters = new counters_1.Counters(); } + /** + * The number a sectioning command should carry, or null when it is starred. + * + * Text transforms call this through their receiver, so the registry stays + * free of parser internals and a third-party transform that does not care + * about numbering keeps working. + * + * @param level - which sectioning level is starting + * @param raw - the command's source, so a starred form can opt out + * @returns the dotted number, or null for an unnumbered heading + */ + sectionNumber(level, raw) { + if (/\\[a-z]+\*/.test(raw)) + return null; + return this.counters.section(level); + } + /** + * The number a theorem-like environment should carry, or null when starred. + * + * @param name - the environment name, such as `theorem` + * @param raw - the `\begin` source, so a starred form can opt out + * @returns the next number for that kind, or null when unnumbered + */ + environmentNumber(name, raw) { + if (/\{[a-z]+\*\}/.test(raw)) + return null; + return this.counters.environment(name); + } + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- parse(text) { + this.diagnostics = []; + // A parser instance is reused across documents; without this the second + // would continue the first one's numbering, and inherit any colour the + // first defined for itself. + this.counters.reset(); + (0, utils_1.resetDefinedColors)(); if (!text) return []; - var lines = text.split('\n'); - this.parseEnvText(lines); - this.parseEnv(lines); + const tree = this.parseTree(text); + this.walk(tree); this.objects.forEach((obj) => { if (obj.type.match(/pspicture/)) { - obj.plot = this.parsePSTricks(obj.lines, obj.env); + obj.plot = this.parsePSTricks(obj.commands || [], obj.env); + delete obj.commands; } }); return this.objects; } - newEnvironment(type) { - if (this.environment && this.environment.lines.length) { - this.environment.settings = { ...this.settings }; - this.objects.push(this.environment); + // ------------------------------------------------------------------------- + // Grammar integration + // ------------------------------------------------------------------------- + parseTree(text) { + try { + return pegParser.parse(text); } - this.environment = { - type: type, - lines: [] + catch (err) { + const loc = err.location || { start: { line: 1, column: 1 } }; + this.diagnostics.push({ + severity: 'error', + message: `parse error: ${err.message || String(err)}`, + line: loc.start.line, + column: loc.start.column + }); + // Degraded fallback: treat the whole input as a math text block. + return [{ kind: 'raw', text: text }]; + } + } + // ------------------------------------------------------------------------- + // Tree walk + // ------------------------------------------------------------------------- + walk(segments) { + this.objects = []; + this.environment = { type: 'math', lines: [] }; + segments.forEach((seg) => this.walkSegment(seg)); + this.newEnvironment('math'); + } + walkSegment(seg) { + if (seg.kind === 'raw') { + seg.text.split('\n').forEach((line) => this.pushMathLine(line)); + return; + } + switch (seg.kind) { + case 'line': + this.walkContent(seg); + break; + case 'env': + this.walkEnv(seg); + break; + case 'strayEnd': + if (this.isIgnored(seg.raw)) + return; + this.diagnose('warning', `unexpected \\end{${seg.name}}`, seg.loc); + break; + } + } + walkEnv(env) { + const name = env.name; + // Ignored wrapper environments (center, document, interactive…) are + // dropped, but their content is still walked in the current context. + if (this.isIgnoredEnv(name)) { + env.content.forEach((c) => this.walkContent(c)); + return; + } + const structural = env.verbatim || !!this.Delimiters[name]; + if (!structural) { + // Non-structural environments (theorem, proof, quotation…) flatten into + // the current environment as header text (handled by the Headers pass). + const inPspicture = this.inPspicture(); + if (inPspicture) + this.pushLine(env.begin.raw); + else + this.pushMathLine(env.begin.raw); + env.content.forEach((c) => this.walkContent(c)); + if (env.end) { + if (inPspicture) + this.pushLine(env.end.raw); + else + this.pushMathLine(env.end.raw); + } + else { + this.diagnose('warning', `unclosed \\begin{${name}}`, env.begin.loc); + } + return; + } + // Structural environment: close the current one and open a new one. + this.newEnvironment(name); + if (!env.verbatim) + this.metaData(name, env); + if (env.verbatim) { + const v = env.content[0]; + this.environment.lines = v && v.kind === 'verbatim' ? v.text.split('\n') : []; + } + else if (name.match(/pspicture/)) { + this.environment.commands = []; + env.content.forEach((c) => this.walkContent(c)); + } + else { + // enumerate / itemize / nicebox: content is text lines (with transforms). + this.walkTextContent(env.content); + } + if (env.end && env.end.name !== name) { + this.diagnose('warning', `\\end{${env.end.name}} does not match \\begin{${name}}`, env.end.loc); + } + else if (!env.end) { + this.diagnose('warning', `unclosed environment '${name}'`, env.begin.loc); + } + this.newEnvironment('math'); + } + /** + * Walk a text environment's content, rejoining the nodes that came from one + * source line. + * + * `EnvContent` matches `Command` before `Line`, and a command's tail stops at + * the next command, so `\item First with \textbf{bold} text` arrives as two + * command nodes. Walking them individually renders each as its own line, + * which broke every list item at its first macro. pspicture keeps the + * per-node walk, because it depends on receiving commands separately. + */ + walkTextContent(content) { + let pending = []; + const flush = () => { + if (!pending.length) + return; + const text = pending + .map((n) => (n.kind === 'line' ? this.lineToString(n) : n.raw)) + .join(''); + pending = []; + this.pushMathLine(text); }; + content.forEach((node) => { + if (node.kind === 'env') { + flush(); + this.walkEnv(node); + return; + } + // An empty Line is the newline itself: it closes the line being built, + // or is a genuine paragraph break when there is nothing to close. + if (node.kind === 'line' && node.parts.length === 0) { + if (pending.length) + flush(); + else + this.pushBlankLine(false); + return; + } + const at = node.loc && node.loc.line; + const open = pending.length ? pending[0].loc && pending[0].loc.line : at; + if (pending.length && at !== open) + flush(); + pending.push(node); + // A Line node consumed its own EOL, so nothing more belongs to it. + if (node.kind === 'line') + flush(); + }); + flush(); + } + /** + * Walk one node of environment content. Behavior depends on the current + * environment: inside pspicture we collect commands (and raw lines) for plot + * extraction; elsewhere lines go through the text/header passes. + */ + walkContent(node) { + const inPspicture = this.inPspicture(); + switch (node.kind) { + case 'line': { + // Comment-only lines are dropped (mirrors the old /^%/ ignore rule). + const allComments = node.parts.length > 0 && node.parts.every((p) => p.kind === 'comment'); + if (allComments) + return; + if (node.parts.length === 0) { + this.pushBlankLine(inPspicture); + return; + } + const text = this.lineToString(node); + if (inPspicture) + this.pushLine(text); + else + this.pushMathLine(text); + break; + } + case 'command': { + if (node.name === 'psset') { + this.parseUnits(node.raw); + return; + } + // The settings in force at THIS point in the source, not at the end of + // it. Commands are collected during the walk and parsed afterwards, so + // reading `this.settings` when they are parsed would give every shape + // in the picture the last \psset rather than the one above it. + if (inPspicture) { + node.settings = { ...this.style }; + // Units are snapshotted separately: they are not style defaults to + // copy onto a shape, they change how its coordinates are computed. + node.units = { + xunit: this.settings.xunit, + yunit: this.settings.yunit, + runit: this.settings.runit + }; + this.environment.commands.push(node); + } + else + this.pushMathLine(node.raw); + break; + } + case 'env': + this.walkEnv(node); + break; + default: + break; + } + } + /** + * Convert a Line node's parts back to a string, dropping comment fragments. + */ + lineToString(line) { + return line.parts + .filter((p) => p.kind !== 'comment') + .map((p) => (p.kind === 'char' ? p.c : p.raw)) + .join(''); + } + // ------------------------------------------------------------------------- + // Line handling + // ------------------------------------------------------------------------- + inPspicture() { + return !!(this.environment && this.environment.type.match(/pspicture/)); + } + pushBlankLine(inPspicture) { + if (inPspicture) + return; + if (this.inPspicture()) + return; + this.environment.lines.push('
    '); } + pushMathLine(text) { + if (this.isIgnored(text)) + return; + if (!text.trim().length) { + this.environment.lines.push('
    '); + return; + } + if (this.PSTricks.Expressions.psset.test(text)) { + this.parseUnits(text); + return; + } + if (DEFINECOLOR_RE.test(text)) { + this.parseDefineColor(text); + return; + } + const processed = this.parseText(text); + if (processed.trim().length) + this.environment.lines.push(processed); + } + /** Raw line inside a pspicture: no text/header transforms (they corrupt + * PSTricks content). */ pushLine(line) { var add = true; this.Ignore.forEach((exp) => { @@ -390,24 +2670,126 @@ class Parser { add = false; } }); - if (add) { - if (typeof line === 'string' && line.trim().length) { - if (this.PSTricks.Expressions.psset.test(line)) { - this.parseUnits(line); - } - else { - this.environment.lines.push(line); - } + if (add && typeof line === 'string' && line.trim().length) { + if (this.PSTricks.Expressions.psset.test(line)) { + this.parseUnits(line); + } + else if (DEFINECOLOR_RE.test(line)) { + this.parseDefineColor(line); + } + else { + this.environment.lines.push(line); + } + } + } + isIgnored(line) { + return this.Ignore.some((exp) => exp.test(line)); + } + isIgnoredEnv(name) { + return this.isIgnored('\\begin{' + name + '}'); + } + /** + * Groups lines into paragraphs, the way TeX does. + * + * TeX has no concept of a blank line as vertical space: a run of them, of + * any length, is a single `\par`, and the gap between paragraphs comes from + * `\parskip` — a style, set once for the document, not something an author + * dials in by pressing return more times. Two blank lines and one are the + * same input. + * + * This used to emit one `
    ` per blank line, so the gap was however many + * times the author happened to hit return, and no stylesheet could adjust + * it. Paragraphs are real elements now and the spacing is theirs, which is + * both what TeX means and the only version a theme can restyle. + * + * Block elements are passed through untouched: a heading, list or picture is + * not part of a paragraph and brings its own margins. + * + * @param lines - the environment's rendered lines + * @returns the lines with runs of text wrapped in paragraphs + */ + paragraphize(lines) { + const isBlock = (l) => /^\s*<(h[1-6]|ul|ol|li|p|div|table|blockquote|pre|figure)\b/i.test(l); + const out = []; + let para = []; + const flush = () => { + if (!para.length) + return; + out.push('

    ' + para.join('\n') + '

    '); + para = []; + }; + for (const line of lines) { + // Any run of these ends the paragraph, and a run is one break however + // long it is — consecutive flushes after the first do nothing. + if (line === '
    ') { + flush(); + continue; + } + if (isBlock(line)) { + flush(); + out.push(line); + continue; + } + para.push(line); + } + flush(); + return out; + } + newEnvironment(type) { + if (this.environment && + (this.environment.lines.length || this.environment.type !== 'math')) { + this.environment.settings = { ...this.settings }; + // Only the plain text environment. A list keeps its \item lines for its + // own component to turn into
  • , verbatim is literal, a picture is + // commands, and a nicebox is a single inline run — wrapping any of those + // in paragraphs breaks the element that consumes them. + if (this.environment.type === 'math') { + this.environment.lines = this.paragraphize(this.environment.lines); } + this.objects.push(this.environment); + } + this.environment = { + type: type, + lines: [] + }; + } + /** + * Records a `\definecolor{name}{model}{spec}`. + * + * xcolor lets a document define its own colours, and a document that wants a + * shade xcolor does not name — a browser colour such as `lightblue`, say — + * can define it rather than rely on the renderer guessing. That is what makes + * such a page valid LaTeX instead of only valid here. + */ + parseDefineColor(text, loc) { + const m = String(text || '').match(DEFINECOLOR_RE); + if (!m) + return; + if (!(0, utils_1.defineColor)(m[1], m[2], m[3])) { + this.diagnose('warning', `\\definecolor{${m[1]}}: the ${JSON.stringify(m[2])} model with ` + + `${JSON.stringify(m[3])} is not one this understands; the colour is left undefined`, loc); } } parseUnits(line) { - var m = line.match(this.PSTricks.Expressions.psset); - Object.assign(this.settings, this.PSTricks.Functions.psset.call(this, m)); + var m = line.replace(/\n/g, ' ').match(this.PSTricks.Expressions.psset); + const declared = this.PSTricks.Functions.psset.call(this, m); + if (declared.dialect) + this.dialect = declared.dialect; + Object.assign(this.settings, declared); + Object.assign(this.style, pssetStyle(declared)); } - metaData(environment, line) { + metaData(environment, envNode) { if (this.PSTricks.Expressions.hasOwnProperty(environment)) { - this.environment.match = line.match(this.PSTricks.Expressions[environment]); + this.environment.match = envNode.begin.raw + .replace(/\n/g, ' ') + .match(this.PSTricks.Expressions[environment]); + if (!this.environment.match) { + this.diagnose('error', `could not parse \\begin{${environment}} arguments`, envNode.begin.loc); + this.environment.env = {}; + this.environment.env.xunit = this.settings.xunit; + this.environment.env.yunit = this.settings.yunit; + return; + } this.environment.env = this.PSTricks.Functions[environment].call(this.settings, this.environment.match); if (environment.match(/pspicture/)) { if (typeof this.environment.env.xunit === 'undefined') { @@ -416,154 +2798,212 @@ class Parser { if (typeof this.environment.env.yunit === 'undefined') { this.environment.env.yunit = this.settings.yunit; } + // A radius is scaled by runit, not by xunit, so it has to reach the + // env too or every circle falls back to the coordinate unit. + if (typeof this.environment.env.runit === 'undefined') { + this.environment.env.runit = this.settings.runit; + } + // Renderers read the dialect for the handful of semantics it changes. + this.environment.env.dialect = this.dialect; } } } - parseEnv(lines) { - this.objects = []; - this.environment = { - type: 'math', - lines: [] - }; - const Delimiters = this.Delimiters; - lines.forEach((line) => { - var isDelim = false; - Object.entries(Delimiters).forEach(([env, type]) => { - Object.entries(type).forEach(([k, delim]) => { - if (line.match(delim)) { - isDelim = true; - if (k.match(/begin/)) { - if (this.environment.type.match(/verbatim/)) { - isDelim = false; - } - else if (this.environment.type.match(/print/)) { - isDelim = false; - } - else { - this.newEnvironment(env); - this.metaData(env, line); - } - } - else if (k.match(/end/)) { - if (this.environment.type.match(/verbatim/)) { - if (env.match(/verbatim/)) { - this.newEnvironment('math'); - } - else { - isDelim = false; - } - } - else if (this.environment.type.match(/print/)) { - if (env.match(/print/)) { - this.newEnvironment('math'); - } - else { - isDelim = false; - } - } - else { - this.newEnvironment('math'); - } - } - } - }); - }); - if (!isDelim) - this.pushLine(line); // } + // ------------------------------------------------------------------------- + // PSTricks command extraction (ordered) + // ------------------------------------------------------------------------- + /** + * Extract plot data from the ordered command nodes of a pspicture. + * Returns the grouped `plot` map (keyed by command type, used by the + * interactive re-render paths) and records the ordered `elements` list on + * the env for source-order initial rendering. + */ + parsePSTricks(commands, env) { + var plot = {}; + const entries = Object.entries(this.PSTricks.Expressions); + entries.forEach(([k, _exp]) => { + plot[k] = []; }); - // push last! - this.newEnvironment('math'); + const elements = []; + this.extractCommands(commands, env, plot, elements); + env.elements = elements; + return plot; } - parseEnvText(lines) { - var _env = 'math'; - const Delimiters = this.Delimiters; - lines.forEach((line, i) => { - var isDelim = false; - Object.entries(Delimiters).forEach(([env, type]) => { - Object.entries(type).forEach(([k, delim]) => { - if (line.match(delim)) { - isDelim = true; - if (k.match(/begin/)) { - if (!_env.match(/verbatim/)) { - _env = env; - } - else { - isDelim = false; - } - } - else if (k.match(/end/)) { - if (!_env.match(/verbatim/)) { - _env = 'math'; - } - else { - if (!env.match(/verbatim/)) { - isDelim = false; - } - else { - _env = 'math'; - } - } - } - } - }); - }); - if (!isDelim) { - if (!_env.match(/verbatim/)) { - lines[i] = this.parseText(line); + /** + * Extract one command node into `plot` (grouped) and `elements` (ordered). + * Recurses into `\multido` bodies (expanded, counter substituted) and + * `\pscustom` bodies (the renderer re-parses those itself — the command is + * kept as a single element with its raw body). + */ + extractCommands(commands, env, plot, elements) { + commands.forEach((node) => { + const k = node.name; + const exp = this.PSTricks.Expressions[k]; + if (!exp) { + this.diagnose('warning', `unknown command \\${k} in pspicture`, node.loc); + return; + } + // The grammar captures commands across lines; the semantic regexes are + // single-line, so collapse internal newlines before matching. + const raw = node.raw.replace(/\n/g, ' '); + const m = raw.match(exp); + if (!m) { + this.diagnose('warning', `could not parse \\${k}: ${JSON.stringify(node.raw)}`, node.loc); + return; + } + // Units declared inside the picture rescale what comes after them. + const cmdEnv = envForUnits(env, node.units); + const data = this.PSTricks.Functions[k].call(cmdEnv, m); + applyPsset(data, node.settings, node.raw); + // An `arrows` declared by \psset arrives as a string after the parse + // function has already turned its own into flags, so it is normalized + // once more here rather than in each of them. + (0, utils_1.normalizeArrows)(data); + // \multido{var=start+step}{count}{body} — expand and recurse. + if (k === 'multido') { + this.expandMultido(data, env, plot, elements, node); + return; + } + // \pscustom{...} — pre-extract the inner commands into pixel data so + // the renderer can build a single filled/stroked path. + if (k === 'pscustom' && data.body) { + data.commands = this.extractCustomBody(data.body, cmdEnv); + } + // \rput(x,y){...} places its contents at (x,y). The contents are usually + // a label, and were assumed to be one — so a graphics command inside an + // rput drew nothing at all, and the tail the greedy regex left over was + // set as text. Graphics are placed by translating a group, which keeps + // them in document order among the other shapes rather than in the + // separate DOM pass the labels go through. + if (k === 'rput') { + const children = this.extractCustomBody(braceGroup(node.raw), cmdEnv); + if (children.length) { + // The contents' own origin lands on (x,y), so the offset is the + // command's position measured from where (0,0) falls. + const originX = (cmdEnv.w - cmdEnv.x1) * cmdEnv.xunit; + const originY = cmdEnv.y1 * cmdEnv.yunit; + elements.push({ + name: 'rputgroup', + data: { dx: data.x - originX, dy: data.y - originY, children }, + match: m, + fn: this.PSTricks.Functions[k], + loc: node.loc + }); + return; + } + } + plot[k].push({ data: data, env: env, match: m, fn: this.PSTricks.Functions[k] }); + // Under the PSTricks reading, anything this project added is worth + // naming. A document that declares the LaTeX2JS dialect has said it means + // to use them, so it is not told again. + if (this.dialect === 'pstricks') { + // `data` carries the command's parsed options, which is what the + // detector inspects alongside the raw source. + for (const use of (0, dialect_1.dialectUses)(k, node.raw ?? '', data)) { + this.diagnose('warning', `${use.construct} is a LaTeX2JS extension: ${use.detail}. ` + + 'Declare \\psset{dialect=latex2js} if that is intended.', node.loc); } - if (!line.trim().length) { - lines[i] = '
    '; + } + if (data && data.plotpointsIgnored !== undefined) { + this.diagnose('warning', `plotpoints=${data.plotpointsIgnored} needs at least 2 samples to mean anything; ` + + 'the default sampling was used instead', node.loc); + } + const bad = nonFiniteFields(data); + if (bad.length) { + this.diagnose('warning', `\\${k} produced no usable value for ${bad.join(', ')}; it will not be drawn`, node.loc); + } + elements.push({ name: k, data: data, match: m, fn: this.PSTricks.Functions[k], loc: node.loc }); + // side effects preserved from the old parser: + if (k === 'psaxes' && plot[k].length > 0) { + const axesData = plot[k][plot[k].length - 1].data; + if (axesData && axesData.dx !== undefined) { + env.dx = axesData.dx; + env.dy = axesData.dy; + env.origin = axesData.origin; } } + if (k === 'uservariable') { + env.variables = env.variables || {}; + env.variables[data.name] = data.value; + } }); } - parsePSExpression(line, exp, plot, k, env) { - var match = line.match(exp); - if (match) { - plot[k].push({ - data: this.PSTricks.Functions[k].call(env, match), - env: env, - match: match, - fn: this.PSTricks.Functions[k] + /** Expand a \multido loop into its constituent commands. */ + expandMultido(data, env, plot, elements, node) { + if (!data.variable || !(data.count > 0) || !data.body) + return; + const re = new RegExp('\\\\' + data.variable + '\\b', 'g'); + for (let i = 0; i < data.count; i++) { + const value = data.start + i * data.step; + const body = data.body.replace(re, String(value)); + this.commandNodesFrom(this.parseTree(body)).forEach((cmd) => { + this.extractCommands([cmd], env, plot, elements); }); - return true; } - return false; } - parsePSVariables(line, exp, _plot, k, env) { - var match = line.match(exp); - if (match) { - if (k.match(/uservariable/)) { - var dd = this.PSTricks.Functions[k].call(env, match); - env.variables = env.variables || {}; - env.variables[dd.name] = dd.value; + /** + * Extract the inner commands of a \pscustom body into pixel data for the + * renderer. Commands that need DOM/runtime handling (rput, slider, psset, + * nested pscustom, multido) are skipped. + */ + extractCustomBody(body, env) { + const out = []; + const skip = ['rput', 'slider', 'psset', 'pspicture', 'pscustom', 'multido', 'uservariable']; + this.commandNodesFrom(this.parseTree(body)).forEach((node) => { + const k = node.name; + if (skip.indexOf(k) !== -1) + return; + const exp = this.PSTricks.Expressions[k]; + if (!exp) + return; + const m = node.raw.replace(/\n/g, ' ').match(exp); + if (!m) + return; + try { + const data = this.PSTricks.Functions[k].call(env, m); + if (data) + out.push({ key: k, data: data }); + } + catch (err) { + /* ignore malformed inner commands */ } - } - } - parsePSTricks(lines, env) { - var plot = {}; - const entries = Object.entries(this.PSTricks.Expressions); - entries.forEach(([k, _exp]) => { - plot[k] = []; - }); - lines.forEach((line) => { - entries.forEach(([k, exp]) => { - this.parsePSVariables(line, exp, plot, k, env); - const result = this.parsePSExpression(line, exp, plot, k, env); - if (result && k === 'psaxes' && plot[k].length > 0) { - const axesData = plot[k][plot[k].length - 1].data; - if (axesData && axesData.dx !== undefined) { - env.dx = axesData.dx; - env.dy = axesData.dy; - env.origin = axesData.origin; - } - } - }); }); - return plot; + return out; } - parseTextExpression(line, exp, k, contents) { - var match = line.match(exp); + /** + * Flatten parsed segments into an ordered list of command nodes, walking + * into line parts and nested environments. + */ + commandNodesFrom(segs) { + const out = []; + const walk = (seg) => { + if (seg.kind === 'command') + out.push(seg); + else if (seg.kind === 'line') { + (seg.parts || []).forEach((p) => { + if (p.kind === 'command') + out.push(p); + }); + } + else if (seg.kind === 'env') { + (seg.content || []).forEach(walk); + } + }; + segs.forEach(walk); + return out; + } + // ------------------------------------------------------------------------- + // Text / header transforms (reused from the old parser, string-based) + // ------------------------------------------------------------------------- + /** + * Text transforms run in sequence over one line, so each must match the + * value the previous ones produced. Matching the pristine line instead makes + * `matchrepl` search `contents` for a literal that an earlier transform has + * already rewritten, and the replacement silently does nothing — which is + * why `\section{Cauchy--Schwarz}` survived as source text once `--` had + * become an en dash. + */ + parseTextExpression(_line, exp, k, contents) { + var match = contents.match(exp); if (match) { return this.Text.Functions[k].call(this, match, contents); } @@ -572,7 +3012,9 @@ class Parser { parseHeadersExpression(line, exp, k, contents) { var match = line.match(exp); if (match) { - return this.Headers.Functions[k].call(this); + // The match is passed so a numbered environment can see whether its + // \begin was starred, which is how LaTeX spells "do not number this one". + return this.Headers.Functions[k].call(this, match); } return contents; } @@ -588,10 +3030,21 @@ class Parser { }); return contents; } + // ------------------------------------------------------------------------- + // Diagnostics + // ------------------------------------------------------------------------- + diagnose(severity, message, loc) { + this.diagnostics.push({ + severity: severity, + message: message, + line: loc ? loc.line : undefined, + column: loc ? loc.column : undefined + }); + } } exports.default = Parser; -},{}],13:[function(require,module,exports){ +},{"../grammar/parser.js":9,"./counters":11,"./dialect":12,"@latex2js/settings":23,"@latex2js/utils":25}],17:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Functions = exports.Expressions = void 0; @@ -616,7 +3069,49 @@ exports.Expressions = { set: /\\set\{[^}]*\}/g, youtube: /\\youtube\{[^}]*\}/g, euler: /Euler\^/g, + textbf: /\\textbf\{[^}]*\}/g, + textit: /\\textit\{[^}]*\}/g, + texttt: /\\texttt\{[^}]*\}/g, + textrm: /\\textrm\{[^}]*\}/g, + textsc: /\\textsc\{[^}]*\}/g, + underline: /\\underline\{[^}]*\}/g, + overline: /\\overline\{[^}]*\}/g, + section: /\\section\*?\{[^}]*\}/, + subsection: /\\subsection\*?\{[^}]*\}/, + subsubsection: /\\subsubsection\*?\{[^}]*\}/, + paragraph: /\\paragraph\{[^}]*\}/, + hspace: /\\hspace\{[^}]*\}/, + noindent: /\\noindent/g, + newpage: /\\newpage/g, + hrule: /\\hrule/g, + rule: /\\rule\{[^}]*\}\{[^}]*\}/g, + textcolor: /\\textcolor\{[^}]*\}\{[^}]*\}/g, + footnote: /\\footnote\{[^}]*\}/g, }; +/** + * Renders one sectioning command, numbered unless it is starred. + * + * The number comes from the parser through the receiver, so this registry + * carries no parser internals and a host that supplies neither still renders + * the heading — just without a number. + * + * @param tag - the heading element for this level + * @param level - the sectioning level, for the counter + * @param m - the match: [full, star, title] + * @param parser - the receiver, when the caller supplied one + * @returns the heading markup + */ +function heading(tag, level, m, parser) { + const starred = m[1] === '*'; + const title = m[2]; + const number = !starred && parser && typeof parser.sectionNumber === 'function' + ? parser.sectionNumber(level, m[0]) + : null; + const label = number === null || number === undefined + ? '' + : '' + number + ' '; + return '<' + tag + '>' + label + title + ''; +} exports.Functions = { cite: function (m, contents) { m.forEach((match) => { @@ -678,13 +3173,65 @@ exports.Functions = { vspace: (0, utils_1.simplerepl)(/\\vspace/g, '
    '), TeX: (0, utils_1.simplerepl)(/\\TeX\\|\\TeX/g, '$\\TeX$'), LaTeX: (0, utils_1.simplerepl)(/\\LaTeX\\|\\LaTeX/g, '$\\LaTeX$'), + textbf: (0, utils_1.matchrepl)(/\\textbf\{([^}]*)\}/, function (m) { + return '' + m[1] + ''; + }), + textit: (0, utils_1.matchrepl)(/\\textit\{([^}]*)\}/, function (m) { + return '' + m[1] + ''; + }), + texttt: (0, utils_1.matchrepl)(/\\texttt\{([^}]*)\}/, function (m) { + return '' + m[1] + ''; + }), + textrm: (0, utils_1.matchrepl)(/\\textrm\{([^}]*)\}/, function (m) { + return '' + m[1] + ''; + }), + textsc: (0, utils_1.matchrepl)(/\\textsc\{([^}]*)\}/, function (m) { + return '' + m[1] + ''; + }), + underline: (0, utils_1.matchrepl)(/\\underline\{([^}]*)\}/, function (m) { + return '' + m[1] + ''; + }), + overline: (0, utils_1.matchrepl)(/\\overline\{([^}]*)\}/, function (m) { + return '' + m[1] + ''; + }), + section: (0, utils_1.matchrepl)(/\\section(\*?)\{([^}]*)\}/, function (m) { + return heading('h2', 'section', m, this); + }), + subsection: (0, utils_1.matchrepl)(/\\subsection(\*?)\{([^}]*)\}/, function (m) { + return heading('h3', 'subsection', m, this); + }), + subsubsection: (0, utils_1.matchrepl)(/\\subsubsection(\*?)\{([^}]*)\}/, function (m) { + return heading('h4', 'subsubsection', m, this); + }), + paragraph: (0, utils_1.matchrepl)(/\\paragraph\{([^}]*)\}/, function (m) { + return '
    ' + m[1] + '
    '; + }), + hspace: (0, utils_1.matchrepl)(/\\hspace\{([^}]*)\}/, function (_m) { + return '  '; + }), + noindent: (0, utils_1.simplerepl)(/\\noindent/g, ''), + newpage: (0, utils_1.simplerepl)(/\\newpage/g, '

    '), + hrule: (0, utils_1.simplerepl)(/\\hrule/g, '
    '), + rule: (0, utils_1.matchrepl)(/\\rule\{([^}]*)\}\{([^}]*)\}/, function (m) { + return (''); + }), + textcolor: (0, utils_1.matchrepl)(/\\textcolor\{([^}]*)\}\{([^}]*)\}/, function (m) { + return '' + m[2] + ''; + }), + footnote: (0, utils_1.matchrepl)(/\\footnote\{([^}]*)\}/, function (m) { + return '' + m[1] + ''; + }), }; exports.default = { Expressions: exports.Expressions, Functions: exports.Functions, }; -},{"@latex2js/utils":20}],14:[function(require,module,exports){ +},{"@latex2js/utils":25}],18:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = String.raw ` @@ -827,16 +3374,31 @@ exports.default = String.raw ` $$ `; -},{}],15:[function(require,module,exports){ +},{}],19:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.loadMathJax = exports.getMathJax = exports.DEFAULT_CONFIG = void 0; +exports.loadMathJax = exports.getMathJax = exports.DEFAULT_CONFIG = exports.DEFAULT_SCRIPT_URL = void 0; +/** + * Where MathJax is loaded from unless a caller overrides it with + * `config.scriptURL`. + * + * Pinned rather than floating on a major tag, so a build is reproducible and + * the URL can carry an integrity hash. Being a single constant is what made + * the move to MathJax 4 a one-line change: v4 dropped the `es5/` directory, so + * the path shape moved as well as the version. + */ +exports.DEFAULT_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/mathjax@4.1.3/tex-chtml.js'; exports.DEFAULT_CONFIG = { tex: { inlineMath: [['$', '$'], ['\\(', '\\)']], displayMath: [['$$', '$$'], ['\\[', '\\]']], processEscapes: true, processEnvironments: true, + // Number AMS environments — equation, align, gather — and resolve \label + // and \ref against those numbers. Equation numbering stays with MathJax + // rather than the parser: it already owns the math, and two systems + // numbering the same document would disagree. + tags: 'ams', packages: ['base', 'ams', 'newcommand', 'configmacros'] }, chtml: { @@ -848,6 +3410,34 @@ exports.DEFAULT_CONFIG = { } } }; +/** + * Merges an override into a base, recursively, without mutating either. + * + * The config is nested more than one level — `chtml.linebreaks` holds both + * `automatic` and `width` — so merging only the top level silently drops the + * siblings of whatever a caller overrides: passing + * `{ chtml: { linebreaks: { width: "80%" } } }` lost `automatic: true`. The + * `MathJaxConfig` type says any subset may be overridden, and this is what + * makes that true. + * + * Arrays replace rather than merge: `tex.packages` and `tex.inlineMath` are + * whole values, and concatenating them would silently keep a default a caller + * meant to remove. + */ +function deepMerge(base, override) { + if (override === undefined) + return base; + const mergeable = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && typeof v !== 'function'; + if (!mergeable(base) || !mergeable(override)) + return override; + const out = { ...base }; + for (const key of Object.keys(override)) { + out[key] = mergeable(base[key]) && mergeable(override[key]) + ? deepMerge(base[key], override[key]) + : override[key]; + } + return out; +} let mathJaxInstance = null; const getMathJax = () => mathJaxInstance || globalThis.MathJax; exports.getMathJax = getMathJax; @@ -856,28 +3446,51 @@ const loadMathJax = async (callback = () => { }, config = exports.DEFAULT_CONFIG callback(); return; } - if (globalThis.MathJax) { - mathJaxInstance = globalThis.MathJax; + // Presence is not readiness. `window.MathJax` holds the configuration object + // long before the library that reads it has loaded, and pre-configuring the + // global is the documented way to set MathJax up — so treating any value + // here as a loaded library meant a page that configured MathJax itself never + // got the script injected at all, and MathJax never loaded. + const existing = globalThis.MathJax; + if (existing && typeof existing.typesetPromise === 'function') { + mathJaxInstance = existing; + callback(); + return; + } + // Someone has already asked for the script; wait for that one rather than + // adding a second copy. + if (typeof document !== 'undefined' && document.getElementById('MathJax-script')) { callback(); return; } + // scriptURL is a loader concern, not a MathJax one: keep it out of the + // config object that is handed to MathJax itself. + const { scriptURL = exports.DEFAULT_SCRIPT_URL, ...mathjaxConfig } = config; + // Three sources, weakest first: our defaults, then any configuration the page + // had already put on the global, then what this caller passed. Without the + // merge a caller passing only { scriptURL } would drop the tex setup — ams, + // tags, equation numbering — entirely; without folding in `existing`, a page + // that pre-configured MathJax would have its settings thrown away by the + // very call that finally loads the library for it. + const preconfigured = existing && typeof existing === 'object' ? existing : {}; + const merged = deepMerge(deepMerge(exports.DEFAULT_CONFIG, preconfigured), mathjaxConfig); try { globalThis.MathJax = { - ...config, + ...merged, startup: { - ...config.startup, + ...merged.startup, ready: () => { globalThis.MathJax.startup.defaultReady(); mathJaxInstance = globalThis.MathJax; - if (config.startup?.ready) { - config.startup.ready(); + if (merged.startup.ready) { + merged.startup.ready(); } callback(); } } }; const script = document.createElement('script'); - script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js'; + script.src = scriptURL; script.async = true; script.id = 'MathJax-script'; script.onload = () => { @@ -896,7 +3509,7 @@ const loadMathJax = async (callback = () => { }, config = exports.DEFAULT_CONFIG }; exports.loadMathJax = loadMathJax; -},{}],16:[function(require,module,exports){ +},{}],20:[function(require,module,exports){ "use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; @@ -947,14 +3560,61 @@ exports.default = { arrow: psgraph_1.arrow, }; -},{"./lib/psgraph":17,"./lib/pstricks":18}],17:[function(require,module,exports){ +},{"./lib/psgraph":21,"./lib/pstricks":22}],21:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.arrow = arrow; const utils_1 = require("@latex2js/utils"); -function arrow(x1, y1, x2, y2) { +/** + * How long to keep waiting for a MathJax that is present but not yet usable. + * Bounded, so a page that configures MathJax and never loads it still shows + * its labels rather than hiding them forever. + */ +const MATHJAX_READY_TIMEOUT_MS = 10000; +/** + * Resolves to a MathJax that can actually typeset, or null if none will be. + * + * `window.MathJax` is present long before it can typeset anything: the loader + * assigns the configuration object to the global and only then injects the CDN + * script, so between those two moments the global exists and `typesetPromise` + * does not. Reading that capability once, synchronously, therefore fails on a + * cold load and succeeds on a warm one — which is why an rput label sat + * off-centre on first paint and corrected itself on reload. It was centred on + * the width of the raw LaTeX, and never measured again once MathJax arrived + * and replaced it with the formula. + * + * The absence of the global is a different case from a global that is not + * ready: a page with no MathJax at all must show its labels immediately, so + * only the second waits. + * + * @returns the usable MathJax, or null when there is none to wait for + */ +function mathJaxWhenReady() { + const usable = (mj) => (mj && typeof mj.typesetPromise === 'function' ? mj : null); + const now = globalThis.MathJax; + if (!now) + return Promise.resolve(null); + if (usable(now)) + return Promise.resolve(now); + return new Promise((resolve) => { + const started = Date.now(); + const poll = () => { + const ready = usable(globalThis.MathJax); + if (ready) + return resolve(ready); + if (Date.now() - started >= MATHJAX_READY_TIMEOUT_MS) + return resolve(null); + setTimeout(poll, 50); + }; + poll(); + }); +} +function arrow(x1, y1, x2, y2, arrowscale) { var t = Math.PI / 6; - var d = 8; + // arrowscale is a multiplier on the 8px default head size; anything that + // is not a positive number falls back to 1 (the PSTricks default). + var scale = Number(arrowscale); + var d = 8 * (scale > 0 ? scale : 1); var dx = x2 - x1, dy = y2 - y1; var l = Math.sqrt(dx * dx + dy * dy); var cost = Math.cos(t); @@ -978,6 +3638,462 @@ function arrow(x1, y1, x2, y2) { context.push('Z'); return context.join(' '); } +/** PSTricks' `curvature=a b c` default. */ +const CURVATURE_DEFAULT = { a: 1, b: 0.1, c: 0 }; +/** + * The control-point scaling PSTricks itself uses, transcribed from the `CC` + * and `IC` procedures in its PostScript prologue (pstricks.pro). + * + * This is not a Catmull-Rom spline, which is what stood here. The difference + * is where the control offset gets its length: Catmull-Rom takes it from the + * chord between a point's two neighbours, so at a sharp turn — where that + * chord collapses — the curve pinches into a cusp. PSTricks takes it from the + * length of the segment being drawn, which stays large through the turn, so + * the curve rounds outward instead. Every psccurve and psecurve in the corpus + * drew with corners the reference does not have. + * + * @param prev - the point before this one + * @param cur - the point the tangent is taken at + * @param next - the point after this one + * @param p - effective curvature parameters, already through `IC` + * @returns the control points just before and just after `cur` + */ +function curveControls(prev, cur, next, p) { + const d0x = cur[0] - prev[0]; + const d0y = cur[1] - prev[1]; + const d1x = next[0] - cur[0]; + const d1y = next[1] - cur[1]; + const l0 = Math.hypot(d0x, d0y); + const l1 = Math.hypot(d1x, d1y); + // The tangent leans toward whichever neighbouring segment is longer, by + // `c` — which IC has already shifted by one, so the default 0 means 1. + const w0 = Math.pow(l1, p.c); + const w1 = Math.pow(l0, p.c); + const tx = d0x * w0 + d1x * w1; + const ty = d0y * w0 + d1y * w1; + const tlen = Math.hypot(tx, ty); + if (!tlen || !isFinite(tlen)) + return { before: [...cur], after: [...cur] }; + // Sharper turns pull the control points in. With the default b of 0.1 this + // is a very weak effect until the path nearly doubles back on itself. + const turn = Math.atan2(d0y, d0x) - Math.atan2(d1y, d1x); + const m = (p.a * Math.pow(Math.abs(Math.cos(turn / 2)), p.b)) / tlen / 2; + return { + before: [cur[0] - l0 * tx * m, cur[1] - l0 * ty * m], + after: [cur[0] + l1 * tx * m, cur[1] + l1 * ty * m], + }; +} +/** + * Reads `curvature` and applies the rescaling PSTricks' `IC` does once before + * a curve is drawn: c is shifted up by one and clamped, and a is folded + * together with the b exponent. + */ +function curvatureParams(ctx) { + const raw = String((ctx && ctx.curvature) ?? '').trim(); + const parts = raw ? raw.split(/[\s,]+/).map(Number) : []; + const a = isFinite(parts[0]) ? parts[0] : CURVATURE_DEFAULT.a; + const b = isFinite(parts[1]) ? parts[1] : CURVATURE_DEFAULT.b; + const c = isFinite(parts[2]) ? parts[2] : CURVATURE_DEFAULT.c; + return { + a: ((a * 2) / 3) / Math.pow(Math.cos(Math.PI / 4), b), + b, + c: Math.min(3, Math.max(0, c + 1)), + }; +} +/** + * Cubic Bézier path through a flat [x0,y0,x1,y1,...] point list. + * + * The three PSTricks curve commands are three different shapes, not one: + * `\pscurve` runs through every point, `\psccurve` wraps back to the start, and + * `\psecurve` uses the first and last points **only** to set the tangents and + * draws just the span between the interior ones. Treating `psecurve` as closed + * — as this did — produced a loop where the reference draws a short open arc. + * + * @param data - flat coordinate pairs + * @param mode - which of the three curves to build + * @param ctx - the shape's parsed data, read for `curvature` + * @returns an SVG path, empty when there are too few points for the mode + */ +function buildCurvePath(data, mode, ctx) { + const pts = []; + for (let i = 0; i < data.length; i += 2) + pts.push([data[i], data[i + 1]]); + const n = pts.length; + const p = curvatureParams(ctx); + const seg = (from, c1, c2, to) => ' C ' + c1[0] + ' ' + c1[1] + ', ' + c2[0] + ' ' + c2[1] + ', ' + to[0] + ' ' + to[1]; + if (mode === 'closed') { + // ClosedCurve wraps the neighbour lookup; PSTricks copies the first three + // points onto the end of the stack to the same effect. + if (n < 3) + return ''; + const at = (i) => pts[((i % n) + n) % n]; + const ctrl = pts.map((_, i) => curveControls(at(i - 1), at(i), at(i + 1), p)); + let d = 'M ' + pts[0][0] + ' ' + pts[0][1]; + for (let i = 0; i < n; i++) { + d += seg(at(i), ctrl[i].after, ctrl[(i + 1) % n].before, at(i + 1)); + } + return d + ' Z'; + } + if (mode === 'endpoints') { + // AltCurve draws P1..Pn-2; the outer points only feed the tangents. + if (n < 4) + return ''; + let d = 'M ' + pts[1][0] + ' ' + pts[1][1]; + for (let i = 1; i < n - 2; i++) { + const after = curveControls(pts[i - 1], pts[i], pts[i + 1], p).after; + const before = curveControls(pts[i], pts[i + 1], pts[i + 2], p).before; + d += seg(pts[i], after, before, pts[i + 1]); + } + return d; + } + // OpenCurve. IC starts with a zero tangent and EOC ends with one, so the + // outermost control point of each end sits on the endpoint itself. + if (n < 3) + return ''; + let d = 'M ' + pts[0][0] + ' ' + pts[0][1]; + for (let i = 0; i < n - 1; i++) { + const after = i === 0 ? pts[0] : curveControls(pts[i - 1], pts[i], pts[i + 1], p).after; + const before = i + 1 === n - 1 ? pts[n - 1] : curveControls(pts[i], pts[i + 1], pts[i + 2], p).before; + d += seg(pts[i], after, before, pts[i + 1]); + } + return d; +} +const TAU = Math.PI * 2; +/** Points to device units, matching the linewidth conversion in pstricks.ts. */ +const PT_TO_PX = 1.333; +/** + * Line directions each hatched fill style draws, as offsets from `hatchangle`. + * PSTricks hatches at `hatchangle` for hlines, ninety degrees off for vlines, + * and both for crosshatch — so the default 45 degrees makes hlines diagonal, + * not horizontal. + */ +const HATCH_DIRECTIONS = { + hlines: [0], + vlines: [90], + crosshatch: [0, 90], +}; +/** PSTricks hatch parameter defaults, in points except the angle and colour. */ +const HATCH_DEFAULTS = { hatchwidth: 0.8, hatchsep: 4, hatchangle: 45, hatchcolor: 'black' }; +let patternSeq = 0; +/** Reads a dimension that may carry a `pt` suffix, in device units. */ +function dimension(value, fallbackPt) { + if (typeof value === 'number' && isFinite(value)) + return value * PT_TO_PX; + const m = typeof value === 'string' ? value.trim().match(/^([\d.]+)\s*(pt)?$/) : null; + return (m ? Number(m[1]) : fallbackPt) * PT_TO_PX; +} +/** + * Whether a shape has any fill at all. Renderers that must close a path before + * it can be filled ask this; the paint itself comes from {@link resolveFill}. + * + * @param ctx - the shape's parsed data + * @returns true when the shape should be built as a closed, fillable region + */ +function hasFill(ctx) { + return !!ctx.filled || (!!ctx.fillstyle && ctx.fillstyle !== 'none'); +} +/** + * Resolves a shape's SVG fill value, defining a hatch pattern when the style + * calls for one. + * + * Every renderer previously spelled this decision itself, in three mutually + * inconsistent ways: `fillstyle=hlines` became a solid fill on pspolygon and + * psarc, and no fill at all on psellipse, pswedge and pscurve. Routing all of + * them through one resolver makes an unimplemented style behave the same + * everywhere, and gives the hatched styles a real rendering. + * + * @param ctx - the shape's parsed data, carrying fillstyle and hatch options + * @param svg - the container the pattern definition is attached to + * @returns an SVG paint value: a colour, a `url(#…)` pattern, or `none` + */ +function resolveFill(ctx, svg) { + const style = ctx.fillstyle ?? 'none'; + // The starred forms set `filled`; they fill flat regardless of style, in the + // fillcolor the author wrote. PSTricks fills them with linecolor instead — + // a difference the dialect reports rather than switches, because rendering + // must not depend on a flag that cannot deliver PSTricks output anyway. + if (ctx.filled || style === 'solid') + return ctx.fillcolor; + if (style === 'none' || !style) + return 'none'; + const starred = style.endsWith('*'); + const directions = HATCH_DIRECTIONS[starred ? style.slice(0, -1) : style]; + // An unrecognised style is not a fill; guessing solid is what made the same + // input render differently depending on the shape. + if (!directions) + return 'none'; + const sep = Math.max(1, dimension(ctx.hatchsep, HATCH_DEFAULTS.hatchsep)); + const width = Math.max(0.2, dimension(ctx.hatchwidth, HATCH_DEFAULTS.hatchwidth)); + const angle = Number(ctx.hatchangle ?? HATCH_DEFAULTS.hatchangle) || 0; + const color = ctx.hatchcolor ?? HATCH_DEFAULTS.hatchcolor; + const id = 'l2j-hatch-' + ++patternSeq; + const pattern = svg + .append('svg:defs') + .append('svg:pattern') + .attr('id', id) + .attr('patternUnits', 'userSpaceOnUse') + .attr('width', sep) + .attr('height', sep) + // SVG's y axis runs opposite to the PSTricks angle convention. + .attr('patternTransform', 'rotate(' + -angle + ')'); + // A starred hatch lays its lines over the fill colour instead of nothing. + if (starred) { + pattern.append('svg:rect').attr('width', sep).attr('height', sep).style('fill', ctx.fillcolor); + } + for (const d of directions) { + const line = pattern.append('svg:line').style('stroke', color).style('stroke-width', width); + if (d === 0) + line.attr('x1', 0).attr('y1', sep / 2).attr('x2', sep).attr('y2', sep / 2); + else + line.attr('x1', sep / 2).attr('y1', 0).attr('x2', sep / 2).attr('y2', sep); + } + return 'url(#' + id + ')'; +} +/** + * Resolves a shape's SVG stroke. + * + * `linestyle=none` means the outline is not drawn at all. Exactly one renderer + * honoured that and the rest painted the outline anyway, so a shape asked to + * show only its fill still came out with a border — the same one-place-right + * pattern that fillstyle had. + * + * @param ctx - the shape's parsed data + * @param fallback - the colour to use when the shape has no linecolor of its own + * @returns an SVG paint value, or `none` when the outline is suppressed + */ +function resolveStroke(ctx, fallback) { + if (ctx && ctx.linestyle === 'none') + return 'none'; + return (ctx && ctx.linecolor) || fallback || 'black'; +} +/** PSTricks' own defaults for the two broken-line styles, in points. */ +const DASH_DEFAULT = '5pt 3pt'; +const DOTSEP_DEFAULT = 3; +/** PSTricks' `dotsize=2pt 2` and `linewidth=0.8pt` defaults. */ +const DOTSIZE_DEFAULT = '2pt 2'; +const DEFAULT_LINEWIDTH_PX = 0.8 * PT_TO_PX; +/** + * The SVG dash pattern for a shape's linestyle, or `none` when it draws solid. + * + * `linestyle=dashed` and `linestyle=dotted` were honoured by psline and + * pspolygon and ignored by every other shape, and where they were honoured + * both emitted the same `9,5` — so a dotted line rendered as a dashed one and + * neither followed the `dash` or `dotsep` the author set. + * + * @param ctx - the shape's parsed data, carrying linestyle, dash and dotsep + * @returns an SVG stroke-dasharray value + */ +function dashArray(ctx) { + const style = (ctx && ctx.linestyle) || 'solid'; + const round = (n) => Math.round(n * 1000) / 1000; + if (style === 'dotted') { + // A zero-length dash under a round cap is how SVG draws a round dot; the + // gap is dotsep plus the width the cap itself occupies. + const sep = dimension(ctx.dotsep, DOTSEP_DEFAULT); + return '0,' + round(sep + (Number(ctx.linewidth) || 0)); + } + if (style !== 'dashed') + return 'none'; + // `dash=5pt 3pt` names the black length then the white one. + const parts = String(ctx.dash ?? DASH_DEFAULT).trim().split(/\s+/); + const on = dimension(parts[0], 5); + const off = dimension(parts[1] ?? parts[0], 3); + return round(on) + ',' + round(off); +} +/** Round caps are what turn a zero-length dash into a dot. */ +function dashCap(ctx) { + return ctx && ctx.linestyle === 'dotted' ? 'round' : 'butt'; +} +/** + * The radius of a plotted dot. + * + * PSTricks reads `dotsize= ` and sets the dot's *diameter* to + * `dim + factor × linewidth`, so a thicker pen draws a proportionally bigger + * dot — its `dotsize=2pt 2` default and the halving are both from + * pstricks-dots.tex. This was a fixed radius that read neither part, so + * `\psdots[linewidth=4pt]` drew the same specks as a hairline where the + * reference draws discs five times the size. + * + * @param ctx - the shape's parsed data, carrying dotsize and linewidth + * @returns the radius in device units + */ +function dotRadius(ctx) { + const parts = String(ctx.dotsize ?? DOTSIZE_DEFAULT).trim().split(/\s+/); + const base = dimension(parts[0], 2); + const factor = Number(parts[1]); + const diameter = base + (isFinite(factor) ? factor : 0) * linewidthPx(ctx); + return Math.max(0.1, diameter / 2); +} +/** + * A shape's linewidth in device units. + * + * Only two of the parse functions convert a `pt` suffix, so the value reaching + * a renderer is sometimes a number of pixels and sometimes the string the + * author wrote. A bare number keeps its device-unit meaning, matching + * parseLinewidth in pstricks.ts. + */ +function linewidthPx(ctx) { + const v = ctx && ctx.linewidth; + if (typeof v === 'number' && isFinite(v)) + return v; + const m = typeof v === 'string' ? v.trim().match(/^([\d.]+)\s*(pt)?$/) : null; + if (!m) + return DEFAULT_LINEWIDTH_PX; + return Number(m[1]) * (m[2] ? PT_TO_PX : 1); +} +/** + * Whether a command's geometry can be drawn. + * + * `X` and `Y` return NaN for a coordinate they cannot compute. Handing that to + * SVG does not fail visibly: an invalid geometry attribute is treated as absent + * and the element falls back to its default, so a broken shape reappears at the + * origin looking intentional. Skipping it is the honest result, and the parser + * has already reported the reason against the source line. + * + * @param ctx - a command's parsed data + * @returns false when any of its own numeric fields is non-finite + */ +function drawable(ctx, depth = 0) { + if (depth > 4 || ctx === null || ctx === undefined) + return true; + if (typeof ctx === 'number') + return isFinite(ctx); + if (Array.isArray(ctx)) + return ctx.every((v) => drawable(v, depth + 1)); + if (typeof ctx !== 'object') + return true; + return Object.entries(ctx).every( + // `global` is the shared environment, not this command's geometry. + ([k, v]) => k === 'global' || k === 'env' || drawable(v, depth + 1)); +} +/** + * SVG arc flags for a PSTricks arc running from `angleA` to `angleB`. + * + * PSTricks always sweeps counter-clockwise in its own coordinates, taking the + * long way round when the end angle precedes the start. `Y` inverts the axis, + * so that counter-clockwise sweep is drawn with SVG's sweep-flag 0 — using 1 + * traces the complementary arc, which is what bowed every `\pswedge` inward + * and turned a pie chart into a star. + * + * @param angleA - start angle in radians + * @param angleB - end angle in radians + * @returns the sweep span plus SVG's large-arc and sweep flags + */ +function arcFlags(angleA, angleB) { + let raw = angleB - angleA; + if (!isFinite(raw)) + raw = 0; + // A span of a full turn or more paints the whole circle: PSTricks keeps + // sweeping past 360 and simply overlaps itself, so \psarc{0}{450} is a + // circle, not the 90 degrees the modulo below leaves behind. Reducing first + // and asking afterwards is what lost the extra turn. + const full = Math.abs(raw) >= TAU - 1e-9; + const delta = ((raw % TAU) + TAU) % TAU; + return { delta, large: delta > Math.PI ? 1 : 0, sweep: 0, full }; +} +/** + * A full turn cannot be expressed as one SVG arc, because the start and end + * points coincide. Such a sweep is emitted as two half-turns instead. + * + * @param cx - centre x in device units + * @param cy - centre y in device units + * @param r - radius in device units + * @returns a closed circular path + */ +function fullCirclePath(cx, cy, r) { + return ('M ' + (cx - r) + ' ' + cy + + ' A ' + r + ' ' + r + ' 0 1 0 ' + (cx + r) + ' ' + cy + + ' A ' + r + ' ' + r + ' 0 1 0 ' + (cx - r) + ' ' + cy + ' Z'); +} +function curveRenderer(svg) { + const mode = this.endpoints ? 'endpoints' : this.closed ? 'closed' : 'open'; + const d = buildCurvePath(this.data, mode, this); + if (!d) + return; + svg + .append('svg:path') + .attr('d', d) + .style('stroke-width', this.linewidth) + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)) + .style('stroke-opacity', 1) + .style('fill', resolveFill(this, svg)); +} +const SVG_NS = 'http://www.w3.org/2000/svg'; +/** + * A selection-like wrapper that patches nodes in place instead of appending. + * + * Renderers call `svg.append('svg:path').attr(...).style(...)` and never + * inspect what they built, so this shim satisfies the same interface while + * reconciling: `append` reuses the child that occupied the same slot last + * time when its tag still matches, and `attr`/`style`/`text` skip writes + * whose value is already there. Appending past the previous child count + * creates nodes; children left over from a bigger previous frame are dropped + * by {@link PatchSelection#prune}. + * + * Keying children by position inside an element is what lets a renderer that + * emits a variable number of nodes — psgrid at a new subdivision, psline with + * a different point count — still patch in place and shed its leftovers. + */ +class PatchSelection { + constructor(node) { + /** Number of appends this pass; public so the caller can prune by it. */ + this.slot = 0; + this.node = node; + } + append(tagName) { + const node = this.node; + if (!node) + return new PatchSelection(null); + const tag = tagName.startsWith('svg:') ? tagName.slice(4) : tagName; + const existing = node.children[this.slot]; + let child; + if (existing && existing.localName === tag) { + child = existing; + } + else { + child = document.createElementNS(SVG_NS, tag); + if (existing) + node.replaceChild(child, existing); + else + node.appendChild(child); + } + this.slot++; + return new PatchSelection(child); + } + attr(name, value) { + if (this.node) { + const v = String(value); + if (this.node.getAttribute(name) !== v) + this.node.setAttribute(name, v); + } + return this; + } + style(name, value) { + if (this.node instanceof SVGElement) { + const v = String(value); + const styles = this.node.style; + if (styles[name] !== v) + styles[name] = v; + } + return this; + } + text(content) { + if (this.node && this.node.textContent !== content) + this.node.textContent = content; + return this; + } + /** Removes children past the last slot written this pass. */ + prune() { + const node = this.node; + if (!node) + return; + while (node.children.length > this.slot) { + node.removeChild(node.children[node.children.length - 1]); + } + } +} const psgraph = { env: null, getSize() { @@ -996,6 +4112,17 @@ const psgraph = { }; }, psframe(svg) { + const filled = hasFill(this); + if (filled) { + svg + .append('svg:rect') + .attr('x', Math.min(this.x1, this.x2)) + .attr('y', Math.min(this.y1, this.y2)) + .attr('width', Math.abs(this.x2 - this.x1)) + .attr('height', Math.abs(this.y2 - this.y1)) + .style('fill', resolveFill(this, svg)) + .style('stroke', 'none'); + } svg .append('svg:line') .attr('x1', this.x1) @@ -1003,7 +4130,9 @@ const psgraph = { .attr('x2', this.x2) .attr('y2', this.y1) .style('stroke-width', 2) - .style('stroke', 'rgb(0,0,0)') + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)) .style('stroke-opacity', 1); svg .append('svg:line') @@ -1012,7 +4141,9 @@ const psgraph = { .attr('x2', this.x2) .attr('y2', this.y2) .style('stroke-width', 2) - .style('stroke', 'rgb(0,0,0)') + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)) .style('stroke-opacity', 1); svg .append('svg:line') @@ -1021,7 +4152,9 @@ const psgraph = { .attr('x2', this.x1) .attr('y2', this.y2) .style('stroke-width', 2) - .style('stroke', 'rgb(0,0,0)') + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)) .style('stroke-opacity', 1); svg .append('svg:line') @@ -1030,24 +4163,46 @@ const psgraph = { .attr('x2', this.x1) .attr('y2', this.y1) .style('stroke-width', 2) - .style('stroke', 'rgb(0,0,0)') + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)) .style('stroke-opacity', 1); }, pscircle: function (svg) { + const filled = hasFill(this); svg .append('svg:circle') .attr('cx', this.cx) .attr('cy', this.cy) .attr('r', this.r) - .style('stroke', 'black') - .style('fill', 'none') - .style('stroke-width', 2) + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)) + .style('fill', resolveFill(this, svg)) + .style('stroke-width', this.linewidth) .style('stroke-opacity', 1); }, psplot(svg) { + // `plotstyle=dots` marks the samples instead of joining them. It was parsed + // and dropped, so a plot asking for dots drew a line through them — or, at + // plotpoints=1, a path of one point, which is nothing at all. That is why + // the tangent markers were missing from every picture in graph.tex. + if (this.plotstyle === 'dots') { + for (let i = 0; i < this.data.length; i += 2) { + svg + .append('svg:circle') + .attr('cx', this.data[i]) + .attr('cy', this.data[i + 1]) + .attr('r', dotRadius(this)) + .attr('class', 'psplot') + .style('fill', this.linecolor) + .style('stroke', 'none'); + } + return; + } var context = []; context.push('M'); - if (this.fillstyle === 'solid') { + if (hasFill(this)) { context.push(this.data[0]); context.push(utils_1.Y.call(this.global, 0)); } @@ -1059,7 +4214,7 @@ const psgraph = { this.data.forEach((data) => { context.push(data); }); - if (this.fillstyle === 'solid') { + if (hasFill(this)) { context.push(this.data[this.data.length - 2]); context.push(utils_1.Y.call(this.global, 0)); context.push('Z'); @@ -1070,8 +4225,10 @@ const psgraph = { .attr('class', 'psplot') .style('stroke-width', this.linewidth) .style('stroke-opacity', 1) - .style('fill', this.fillstyle === 'none' ? 'none' : this.fillcolor) - .style('stroke', this.linecolor); + .style('fill', resolveFill(this, svg)) + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)); }, pspolygon(svg) { var context = []; @@ -1088,206 +4245,274 @@ const psgraph = { .attr('d', context.join(' ')) .style('stroke-width', this.linewidth) .style('stroke-opacity', 1) - .style('fill', this.fillstyle === 'none' ? 'none' : this.fillcolor) - .style('stroke', 'black'); + .style('fill', resolveFill(this, svg)) + // Was hardcoded black, so linecolor and linestyle=none were both ignored. + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)); }, psarc(svg) { - var context = []; - context.push('M'); - context.push(this.cx); - context.push(this.cy); - context.push('L'); - context.push(this.A.x); - context.push(this.A.y); - context.push('A'); - context.push(this.A.x); - context.push(this.A.y); - context.push(0); - context.push(0); - context.push(0); - context.push(this.B.x); - context.push(this.B.y); + const { delta, large, sweep, full } = arcFlags(this.angleA, this.angleB); + const filled = hasFill(this); + const arc = ' A ' + this.r + ' ' + this.r + ' 0 ' + large + ' ' + sweep + + ' ' + this.B.x + ' ' + this.B.y; + const d = full || delta === 0 + ? fullCirclePath(this.cx, this.cy, this.r) + : filled + ? 'M ' + this.cx + ' ' + this.cy + ' L ' + this.A.x + ' ' + this.A.y + arc + ' Z' + : 'M ' + this.A.x + ' ' + this.A.y + arc; svg .append('svg:path') - .attr('d', context.join(' ')) - .style('stroke-width', 2) + .attr('d', d) + .style('stroke-width', this.linewidth) .style('stroke-opacity', 1) - .style('fill', 'blue') - .style('stroke', 'black'); + .style('fill', resolveFill(this, svg)) + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)); }, psaxes(svg) { var xaxis = [this.bottomLeft[0], this.topRight[0]]; var yaxis = [this.bottomLeft[1], this.topRight[1]]; var origin = this.origin; + // Resolved once here: the helper below is an inner function, so it cannot + // reach the axes' own data through `this`. + const axisStroke = resolveStroke(this); function line(x1, y1, x2, y2) { svg .append('svg:path') .attr('d', 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2) .style('stroke-width', 2) - .style('stroke', 'rgb(0,0,0)') + .style('stroke', axisStroke) .style('stroke-opacity', 1); } + /** + * Tick positions, stepped outward from the origin rather than from the end + * of the axis. Starting at the end puts every mark at whatever offset the + * axis happens to begin on, so an axis spanning -3.5 to 3.5 was ticked and + * labelled at half-integers instead of on the whole numbers. + */ + /** + * An axis end that carries an arrowhead, or null. `arrows[0]` points at the + * low end of each axis and `arrows[1]` at the high end, matching the order + * the arrowheads are drawn below. + */ + const arrowedEnds = (axis) => [ + this.arrows[0] ? axis[0] : null, + this.arrows[1] ? axis[1] : null, + ]; + const positions = (from, to, at, step) => { + if (!(step > 0) || !isFinite(step)) + return []; + // Y inverts the axis, so a vertical span arrives with its ends the other + // way round. Walking it as given produced no y ticks at all. + const lo = Math.min(from, to); + const hi = Math.max(from, to); + const out = []; + for (let v = at; v <= hi + 1e-6; v += step) + out.push(v); + for (let v = at - step; v >= lo - 1e-6; v -= step) + out.unshift(v); + // PSTricks gives an arrowhead the end of the axis to itself: where one is + // drawn, the tick and its number are both suppressed. A tick that merely + // falls short of the tip keeps them, so only a coincident one is dropped. + const suppressed = arrowedEnds([from, to]).filter((v) => v !== null); + return out.filter((v) => !suppressed.some((end) => Math.abs(v - end) < 1e-6)); + }; var xticks = () => { - for (var x = xaxis[0]; x <= xaxis[1]; x += this.dx) { + positions(xaxis[0], xaxis[1], origin[0], this.dx).forEach((x) => { + // showorigin=false suppresses the tick at the origin itself. + if (this.showorigin === false && Math.abs(x - origin[0]) < 1e-6) + return; line(x, origin[1] - 5, x, origin[1] + 5); - } + }); }; var yticks = () => { - for (var y = yaxis[0]; y <= yaxis[1]; y += this.dy) { + positions(yaxis[0], yaxis[1], origin[1], this.dy).forEach((y) => { + if (this.showorigin === false && Math.abs(y - origin[1]) < 1e-6) + return; line(origin[0] - 5, y, origin[0] + 5, y); - } + }); + }; + const env = this.global || {}; + /** Draws one tick number, positioned clear of its axis. */ + const label = (text, x, y, anchor) => { + svg + .append('svg:text') + .attr('x', x) + .attr('y', y) + .attr('text-anchor', anchor) + .attr('font-size', 13) + .attr('font-family', 'serif') + .style('fill', 'black') + .text(text); + }; + /** Tick values are device coordinates; labels need the value they stand for. */ + const value = (device, axis) => { + const n = axis === 'x' + ? device / env.xunit - env.w + env.x1 + : env.y1 - device / env.yunit; + return Math.abs(n) < 1e-9 ? 0 : Number(n.toFixed(4)); + }; + const xlabels = () => { + positions(xaxis[0], xaxis[1], origin[0], this.dx).forEach((x) => { + // The origin's number sits directly under the y axis, which would draw + // the axis line straight through the glyph, so it shifts clear of it + // and serves both axes — as it does on a hand-drawn pair of axes. + const atOrigin = Math.abs(x - origin[0]) < 1e-6; + // showorigin=false drops the number at the origin with its tick. + if (atOrigin && this.showorigin === false) + return; + if (atOrigin) + label(String(value(x, 'x')), x - 7, origin[1] + 20, 'end'); + else + label(String(value(x, 'x')), x, origin[1] + 20, 'middle'); + }); + }; + const ylabels = () => { + positions(yaxis[0], yaxis[1], origin[1], this.dy).forEach((y) => { + // The origin's own number belongs to the x axis; drawing it again here + // would stack two glyphs in the same place. + if (Math.abs(y - origin[1]) < 1e-6) + return; + label(String(value(y, 'y')), origin[0] - 10, y + 4, 'end'); + }); }; line(xaxis[0], origin[1], xaxis[1], origin[1]); line(origin[0], yaxis[0], origin[0], yaxis[1]); - if (this.ticks.match(/all/)) { - xticks(); - yticks(); - } - else if (this.ticks.match(/x/)) { + const selects = (option, axis) => { + const v = String(option ?? 'all'); + if (v.match(/none/)) + return false; + return !!(v.match(/all/) || v.match(axis)); + }; + if (selects(this.ticks, 'x')) xticks(); - } - else if (this.ticks.match(/y/)) { + if (selects(this.ticks, 'y')) yticks(); - } + if (env.xunit && selects(this.labels, 'x')) + xlabels(); + if (env.yunit && selects(this.labels, 'y')) + ylabels(); if (this.arrows[0]) { svg .append('path') - .attr('d', arrow(xaxis[1], origin[1], xaxis[0], origin[1])) + .attr('d', arrow(xaxis[1], origin[1], xaxis[0], origin[1], this.arrowscale)) .style('fill', 'black') .style('stroke', 'black'); svg .append('path') - .attr('d', arrow(origin[0], yaxis[1], origin[0], yaxis[0])) + .attr('d', arrow(origin[0], yaxis[1], origin[0], yaxis[0], this.arrowscale)) .style('fill', 'black') .style('stroke', 'black'); } if (this.arrows[1]) { svg .append('path') - .attr('d', arrow(xaxis[0], origin[1], xaxis[1], origin[1])) + .attr('d', arrow(xaxis[0], origin[1], xaxis[1], origin[1], this.arrowscale)) .style('fill', 'black') .style('stroke', 'black'); svg .append('path') - .attr('d', arrow(origin[0], yaxis[0], origin[0], yaxis[1])) + .attr('d', arrow(origin[0], yaxis[0], origin[0], yaxis[1], this.arrowscale)) .style('fill', 'black') .style('stroke', 'black'); } }, psline(svg) { - var linewidth = this.linewidth, linecolor = this.linecolor; - function solid(x1, y1, x2, y2) { - svg - .append('svg:path') - .attr('d', 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2) - .style('stroke-width', linewidth) - .style('stroke', linecolor) - .style('stroke-opacity', 1); - } - function dashed(x1, y1, x2, y2) { - svg - .append('svg:path') - .attr('d', 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2) - .style('stroke-width', linewidth) - .style('stroke', linecolor) - .style('stroke-dasharray', '9,5') - .style('stroke-opacity', 1); - } - function dotted(x1, y1, x2, y2) { + var linewidth = this.linewidth, + // Resolved here so `linestyle=none` suppresses the stroke: the helpers + // below are inner functions and cannot reach the shape through `this`. + linecolor = resolveStroke(this); + // One drawing function for all three styles. There used to be three, and + // `dashed` and `dotted` were byte-identical — both hardcoded `9,5` — so a + // dotted line rendered as a dashed one. + const dash = dashArray(this); + const cap = dashCap(this); + function draw(x1, y1, x2, y2) { svg .append('svg:path') .attr('d', 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2) .style('stroke-width', linewidth) .style('stroke', linecolor) - .style('stroke-dasharray', '9,5') + .style('stroke-dasharray', dash) + .style('stroke-linecap', cap) .style('stroke-opacity', 1); } - if (this.linestyle.match(/dotted/)) { - dotted(this.x1, this.y1, this.x2, this.y2); - } - else if (this.linestyle.match(/dashed/)) { - dashed(this.x1, this.y1, this.x2, this.y2); - } - else { - solid(this.x1, this.y1, this.x2, this.y2); - } - if (this.dots[0]) { - svg - .append('svg:circle') - .attr('cx', this.x1) - .attr('cy', this.y1) - .attr('r', 3) - .style('stroke', this.linecolor) - .style('fill', this.linecolor) - .style('stroke-width', 1) - .style('stroke-opacity', 1); + // Every segment of the polyline. A two-point line is the same drawing it + // always was; anything past the second point used to be dropped. + const pts = this.points && this.points.length >= 2 + ? this.points + : [[this.x1, this.y1], [this.x2, this.y2]]; + for (let i = 0; i < pts.length - 1; i++) { + draw(pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1]); } - if (this.dots[1]) { + // The markers go on the ENDS of the polyline, like the arrowheads below; + // x1..y2 name the first segment, which on a three-point line is its + // middle vertex. + const marker = (at) => { svg .append('svg:circle') - .attr('cx', this.x2) - .attr('cy', this.y2) - .attr('r', 3) - .style('stroke', this.linecolor) + .attr('cx', at[0]) + .attr('cy', at[1]) + .attr('r', dotRadius(this)) + .style('stroke', resolveStroke(this)) .style('fill', this.linecolor) .style('stroke-width', 1) .style('stroke-opacity', 1); - } - var x1 = this.x1, y1 = this.y1, x2 = this.x2, y2 = this.y2; + }; + if (this.dots[0]) + marker(pts[0]); + if (this.dots[1]) + marker(pts[pts.length - 1]); + // An arrowhead belongs on the END of the polyline, and points along the + // last segment. Reading x1..y2 put it on the first segment instead, so a + // three-point line grew a head at its middle vertex. + const head = pts[pts.length - 1]; + const beforeHead = pts[pts.length - 2]; + const tail = pts[0]; + const afterTail = pts[1]; if (this.arrows[0]) { svg .append('path') - .attr('d', arrow(x2, y2, x1, y1)) + .attr('d', arrow(afterTail[0], afterTail[1], tail[0], tail[1], this.arrowscale)) .style('fill', this.linecolor) - .style('stroke', this.linecolor); + .style('stroke', resolveStroke(this)); } if (this.arrows[1]) { svg .append('path') - .attr('d', arrow(x1, y1, x2, y2)) + .attr('d', arrow(beforeHead[0], beforeHead[1], head[0], head[1], this.arrowscale)) .style('fill', this.linecolor) - .style('stroke', this.linecolor); + .style('stroke', resolveStroke(this)); } }, userline(svg) { - var linewidth = this.linewidth, linecolor = this.linecolor; - function solid(x1, y1, x2, y2) { - svg - .append('svg:path') - .attr('class', 'userline') - .attr('d', 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2) - .style('stroke-width', linewidth) - .style('stroke', linecolor) - .style('stroke-opacity', 1); - } - function dashed(x1, y1, x2, y2) { + var linewidth = this.linewidth, + // Resolved here so `linestyle=none` suppresses the stroke: the helpers + // below are inner functions and cannot reach the shape through `this`. + linecolor = resolveStroke(this); + // One drawing function for all three styles; see the note in psline. + const dash = dashArray(this); + const cap = dashCap(this); + function draw(x1, y1, x2, y2) { svg .append('svg:path') - .attr('d', 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2) .attr('class', 'userline') - .style('stroke-width', linewidth) - .style('stroke', linecolor) - .style('stroke-dasharray', '9,5') - .style('stroke-opacity', 1); - } - function dotted(x1, y1, x2, y2) { - svg - .append('svg:path') .attr('d', 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2) - .attr('class', 'userline') .style('stroke-width', linewidth) .style('stroke', linecolor) - .style('stroke-dasharray', '9,5') + .style('stroke-dasharray', dash) + .style('stroke-linecap', cap) .style('stroke-opacity', 1); } - if (this.linestyle.match(/dotted/)) { - dotted(this.x1, this.y1, this.x2, this.y2); - } - else if (this.linestyle.match(/dashed/)) { - dashed(this.x1, this.y1, this.x2, this.y2); - } - else { - solid(this.x1, this.y1, this.x2, this.y2); + // Every segment of the polyline. A two-point line is the same drawing it + // always was; anything past the second point used to be dropped. + const pts = this.points && this.points.length >= 2 + ? this.points + : [[this.x1, this.y1], [this.x2, this.y2]]; + for (let i = 0; i < pts.length - 1; i++) { + draw(pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1]); } if (this.dots[0]) { svg @@ -1296,7 +4521,7 @@ const psgraph = { .attr('cy', this.y1) .attr('r', 3) .attr('class', 'userline') - .style('stroke', this.linecolor) + .style('stroke', resolveStroke(this)) .style('fill', this.linecolor) .style('stroke-width', 1) .style('stroke-opacity', 1); @@ -1308,7 +4533,7 @@ const psgraph = { .attr('cy', this.y2) .attr('r', 3) .attr('class', 'userline') - .style('stroke', this.linecolor) + .style('stroke', resolveStroke(this)) .style('fill', this.linecolor) .style('stroke-width', 1) .style('stroke-opacity', 1); @@ -1317,20 +4542,42 @@ const psgraph = { if (this.arrows[0]) { svg .append('path') - .attr('d', arrow(x2, y2, x1, y1)) + .attr('d', arrow(x2, y2, x1, y1, this.arrowscale)) .attr('class', 'userline') .style('fill', this.linecolor) - .style('stroke', this.linecolor); + .style('stroke', resolveStroke(this)); } if (this.arrows[1]) { svg .append('path') - .attr('d', arrow(x1, y1, x2, y2)) + .attr('d', arrow(x1, y1, x2, y2, this.arrowscale)) .attr('class', 'userline') .style('fill', this.linecolor) - .style('stroke', this.linecolor); + .style('stroke', resolveStroke(this)); } }, + /** + * Graphics placed by an `\rput`, drawn into a translated group. + * + * The label form of rput is handled separately, in the DOM pass below. This + * is the case where the contents are shapes: they are drawn here so they + * keep their place in document order, which the DOM pass cannot express + * because it appends after the SVG is finished. + */ + rputgroup(svg) { + const g = svg + .append('svg:g') + .attr('class', 'rput-group') + .attr('transform', 'translate(' + this.dx + ',' + this.dy + ')'); + (this.children || []).forEach((child) => { + if (!child || !psgraph.hasOwnProperty(child.key)) + return; + if (!drawable(child.data)) + return; + child.data.global = this.global; + psgraph[child.key].call(child.data, g); + }); + }, rput(el) { // Import debug utilities const startTime = Date.now(); @@ -1416,7 +4663,9 @@ const psgraph = { }; // Enhanced MathJax processing with better async handling const processContent = async () => { - const mathJax = window.MathJax; + // Awaited, not read once: the global is assigned before the library it + // names has loaded, so a synchronous check races the CDN. + const mathJax = await mathJaxWhenReady(); if (mathJax && mathJax.typesetPromise) { try { // Set content before MathJax processing @@ -1458,89 +4707,603 @@ const psgraph = { pspicture(svg) { var env = this.env; var el = this.$el; - Object.keys(this.plot).forEach((key) => { - const plot = this.plot[key]; - if (key.match(/rput/)) + const plots = this.plot; + // The parser records `env.elements` in document order, so fills sit under + // lines exactly as authored. + const elements = env && env.elements; + /** + * Recomputes an interactive element against the pointer position and the + * current variable values. + * + * Only elements whose data is derived from something that moves can change: + * a psplot re-runs its sampled function against the variables it names, and + * a userline re-evaluates its head/tail expressions at the pointer. Anything + * else keeps the geometry the parser produced. + */ + function resolveDynamic(item, coords, variables) { + if (item.name === 'psplot') { + Object.entries(variables || {}).forEach(([name, value]) => { + env.variables[name] = value; + }); + const d = item.fn.call(env, item.match); + d.global = Object.assign({}, env); + return d; + } + if (item.name === 'userline') { + // The head/tail expressions can only be evaluated against a pointer; + // without one the element keeps the geometry it was parsed with. + if (!coords) + return item.data; + const d = item.fn.call(env, item.match); + env.x2 = coords[0]; + env.y2 = coords[1]; + item.data.x2 = env.x2; + item.data.y2 = env.y2; + if (item.data.xExp2) { + item.data.x2 = d.userx2(coords); + item.data.x1 = d.userx(coords); + } + else if (item.data.xExp) { + item.data.x2 = d.userx(coords); + } + if (item.data.yExp2) { + item.data.y2 = d.usery2(coords); + item.data.y1 = d.usery(coords); + } + else if (item.data.yExp) { + item.data.y2 = d.usery(coords); + } + d.global = Object.assign({}, env); + Object.assign(d, item.data); + return d; + } + return item.data; + } + /** + * The entry the first frame uses: without a pointer position the parser's + * own data is the contract. Re-running a plot's function here would resolve + * variables that appear later in the document, which the parse-time data + * deliberately does not see. + */ + function resolveData(item, coords, variables) { + if (!coords || !item.fn) + return item.data; + return resolveDynamic(item, coords, variables); + } + /** Evaluates every \uservariable at the pointer position, in source order. */ + function readVariables(coords) { + const variables = {}; + const source = elements && elements.length + ? elements.filter((i) => i && i.name === 'uservariable') + : ((plots && plots.uservariable) || []).map((p) => ({ ...p, name: 'uservariable' })); + source.forEach((item) => { + env.userx = coords[0]; + env.usery = coords[1]; + const dd = item.fn.call(env, item.match); + variables[item.data.name] = dd.value; + }); + return variables; + } + // ---- dependency graph ------------------------------------------------ + /** + * The variables an algebraic expression reads, minus the pointer/sampling + * names x and y that every evaluation scope supplies. Parsed with the same + * compiler the plot sampler uses, so function names and math constants + * never count as dependencies. + */ + function expressionVariables(expr) { + const src = String(expr).replace(/^\{/, '').replace(/\}$/, ''); + try { + return (0, utils_1.parseExpression)(src) + .variables() + .filter((n) => n !== 'x' && n !== 'y'); + } + catch { + return []; + } + } + /** + * What an element's output depends on, derived from the element's own data + * and match rather than from a list of command names: + * + * - a data field whose value is a function — userline's userx/usery/… + * — is evaluated against the pointer on every move: the pointer dependency; + * - every expression string the element carries (uservariable's func, + * userline's head and tail, psplot's sampled function and its range) + * names the variables it reads. + * + * Anything else is static: the parser has already resolved its geometry. + */ + const depsCache = new Map(); + function depsFor(item) { + const cached = depsCache.get(item); + if (cached) + return cached; + const data = item && item.data; + const vars = new Set(); + let pointer = false; + if (data) { + pointer = Object.values(data).some((v) => typeof v === 'function'); + for (const k of ['func', 'xExp', 'yExp', 'xExp2', 'yExp2']) { + const expr = data[k]; + if (typeof expr === 'string' && expr) { + expressionVariables(expr).forEach((n) => vars.add(n)); + } + } + } + // psplot's sampled function and its sample range live in the parser's + // match record, not in the resolved data. + if (item && item.match) { + for (const g of [2, 3, 4]) { + const expr = item.match[g]; + if (typeof expr === 'string' && expr) { + expressionVariables(expr).forEach((n) => vars.add(n)); + } + } + } + const deps = { pointer, variables: [...vars] }; + depsCache.set(item, deps); + return deps; + } + // ---- incremental reconciliation -------------------------------------- + let layer = null; // SVGSelection over the picture layer + let layerNode = null; + // A group per renderable element, tracked by the element itself so a + // group follows its element when the list grows or shrinks; the group's + // `data-key` attribute carries the element's current index in document + // order, which is what keeps the reconciliation ordered. + const groups = new Map(); + const drawn = new Set(); // elements whose group holds output + let lastCoords = null; + let lastVariables = {}; + /** Drops the layer and everything reconciled into it. */ + function clearLayer() { + if (layer) + layer.remove(); + groups.clear(); + drawn.clear(); + layer = svg.append('svg:g').attr('class', 'pspicture-layer'); + layerNode = layer.node(); + } + /** + * Draws the picture, reconciling against what is already on screen. + * + * Every renderable element gets its own , keyed by the element and + * placed in document order, so reusing the node in place is what keeps + * painting order faithful to the source. The full-redraw alternative + * could not: removing just the interactive elements and appending them + * again put them at the end of the SVG, on top of every later shape, and + * re-emitted them grouped by command type rather than in document order, + * so a correct diagram silently reordered itself the first time the + * pointer crossed it. + * + * An element is only re-rendered when something it depends on actually + * changed: the pointer (userline), or one of the variables its expressions + * name — a psplot re-runs only when a referenced \uservariable or slider + * variable moved. Static elements keep their nodes untouched. + */ + function drawLayer(coords, opts) { + opts = opts || {}; + // Legacy data without an ordered element list cannot express author + // order at all, so it keeps the wholesale rebuild: there is no order to + // preserve and no per-element state worth reconciling. + if (!elements || !elements.length) { + clearLayer(); + const variables = coords ? readVariables(coords) : {}; + Object.keys(plots).forEach((key) => { + if (key === 'rput') + return; + if (!psgraph.hasOwnProperty(key)) + return; + plots[key].forEach((entry) => { + const item = { name: key, data: entry.data, match: entry.match, fn: entry.fn }; + const data = resolveData(item, coords, variables); + data.global = env; + psgraph[key].call(data, layer); + }); + }); return; - if (psgraph.hasOwnProperty(key)) { - plot.forEach((data) => { - data.data.global = env; - psgraph[key].call(data.data, svg); + } + if (opts.force) + clearLayer(); + if (!layerNode) + clearLayer(); + const variables = coords ? readVariables(coords) : {}; + const changed = new Set(opts.changed || []); + if (coords) { + // The \uservariable values that moved with the pointer — the only way + // a plot's data can differ between two pointer positions. + Object.entries(variables).forEach(([name, value]) => { + if (!(name in lastVariables) || lastVariables[name] !== value) + changed.add(name); }); + lastVariables = variables; + lastCoords = coords; } - }); + const pointerMoved = !!coords && !!opts.pointer; + let prevGroup = null; + const touched = new Set(); + for (let i = 0; i < elements.length; i++) { + const item = elements[i]; + // Exact, not a pattern: `rputgroup` is the graphics form of rput and + // must be drawn here. Only the label form goes through the DOM pass. + if (!item || !item.name || item.name === 'rput') + continue; + if (!psgraph.hasOwnProperty(item.name)) + continue; + touched.add(item); + const deps = depsFor(item); + const needs = opts.force || + !drawn.has(item) || + (deps.pointer && pointerMoved) || + deps.variables.some((n) => changed.has(n)); + // A group that is not re-rendered still needs its key brought up to + // date: when a neighbouring element left the picture, every later + // element's index shifted. + const key = (g) => { + if (g.getAttribute('data-key') !== String(i)) + g.setAttribute('data-key', String(i)); + }; + if (!needs) { + const group = groups.get(item); + if (group) { + key(group); + prevGroup = group; + } + continue; + } + let data; + if (coords || opts.force) { + data = resolveData(item, coords, variables); + } + else if (item.name === 'psplot') { + // A slider-only re-render still has to re-run the plot's function: + // resolveData would hand back the parse-time data and swallow the + // move, because the new value lives in env.variables, not the + // pointer. userline geometry is pointer-driven, so in this case it + // keeps its last drawn state. + data = resolveDynamic(item, coords, variables); + } + else { + data = item.data; + } + // A coordinate that could not be computed arrives as NaN. Drawing it + // anyway is the trap: SVG treats an invalid geometry attribute as + // absent and falls back to its default, so the shape reappears at the + // origin looking deliberate. Nothing is the honest output. + if (!drawable(data)) { + const stale = groups.get(item); + if (stale) { + stale.remove(); + groups.delete(item); + } + drawn.delete(item); + continue; + } + let group = groups.get(item); + if (!group) { + group = document.createElementNS(SVG_NS, 'g'); + group.setAttribute('data-key', String(i)); + if (prevGroup && prevGroup.nextSibling) { + layerNode.insertBefore(group, prevGroup.nextSibling); + } + else { + layerNode.appendChild(group); + } + groups.set(item, group); + } + else { + key(group); + } + prevGroup = group; + data.global = env; + const sel = new PatchSelection(group); + psgraph[item.name].call(data, sel); + if (sel.slot === 0) { + // The renderer drew nothing (a curve with too few points, say): an + // earlier frame's shapes must not linger under the new data. + while (group.firstChild) + group.removeChild(group.firstChild); + drawn.delete(item); + } + else { + sel.prune(); + drawn.add(item); + } + } + // Elements that left the picture (or whose geometry went bad) drop + // their group; the rest stay exactly where they were. + groups.forEach((group, item) => { + if (!touched.has(item)) { + group.remove(); + groups.delete(item); + drawn.delete(item); + } + }); + } + // The first frame is a full redraw: nothing is on screen yet. + drawLayer(null, { force: true }); + // Expose the incremental renderer so the slider component and the tests + // can drive re-renders the way pointer events do. + this.redraw = (arg) => { + if (Array.isArray(arg) || arg === null || arg === undefined) { + drawLayer(arg === undefined ? lastCoords : arg, { pointer: true }); + } + else { + drawLayer(arg.coords !== undefined ? arg.coords : lastCoords, arg); + } + }; svg.on('touchmove', function (event) { event.preventDefault(); var touch = event.touches ? event.touches[0] : null; var rect = event.target.getBoundingClientRect(); var touchcoords = touch ? [touch.clientX - rect.left, touch.clientY - rect.top] : [0, 0]; - userEvent(touchcoords); + drawLayer(touchcoords, { pointer: true }); }); svg.on('mousemove', function (event) { var coords = [event.offsetX || 0, event.offsetY || 0]; - userEvent(coords); + drawLayer(coords, { pointer: true }); }); - const plots = this.plot; - function userEvent(coords) { - svg.selectAll('.userline').remove(); - svg.selectAll('.psplot').remove(); - var currentEnvironment = {}; - Object.entries(plots || {}) - .forEach(([k, plot]) => { - if (k.match(/uservariable/)) { - plot.forEach((data) => { - data.env.userx = coords[0]; - data.env.usery = coords[1]; - var dd = data.fn.call(data.env, data.match); - currentEnvironment[data.data.name] = dd.value; - }); + // Enhanced cleanup and RPUT processing + psgraph.processRputElements.call(this, el); + }, + psdots(svg) { + for (let i = 0; i < this.data.length; i += 2) { + svg + .append('svg:circle') + .attr('cx', this.data[i]) + .attr('cy', this.data[i + 1]) + .attr('r', dotRadius(this)) + .style('fill', this.linecolor) + .style('stroke', 'none'); + } + }, + /** + * A PSTricks grid is three things, not one: fine subdivision lines, a heavier + * line on each unit, and the coordinate numbered along the left and bottom + * edges. Only the unit lines were drawn, in `linecolor` — which `gridcolor` + * could not override — so a grid was a flat mesh with no reading on it. + */ + psgrid(svg) { + const x0 = this.x0, y0 = this.y0, x1 = this.x1, y1 = this.y1; + const gridcolor = this.gridcolor ?? this.linecolor; + const gridwidth = dimension(this.gridwidth, 0.8); + const subdiv = Math.max(0, Math.floor(Number(this.subgriddiv ?? 5))); + const subcolor = this.subgridcolor ?? 'gray'; + const subwidth = dimension(this.subgridwidth, 0.4); + const rule = (a, b, c, d, color, width) => { + svg + .append('svg:line') + .attr('x1', a).attr('y1', b).attr('x2', c).attr('y2', d) + .style('stroke', color) + .style('stroke-width', width) + .style('stroke-opacity', 1); + }; + /** Line offsets across a span, stepping by `step` from `origin`. */ + const rungs = (lo, hi, origin, step) => { + if (!(step > 0) || !isFinite(step)) + return []; + const out = []; + for (let v = origin; v <= hi + 1e-6; v += step) + out.push(v); + for (let v = origin - step; v >= lo - 1e-6; v -= step) + out.unshift(v); + return out; + }; + const ox = this.originX ?? x0; + const oy = this.originY ?? y0; + // Subdivisions first, so the unit lines and labels sit over them. + if (subdiv > 1) { + for (const x of rungs(x0, x1, ox, this.xunit / subdiv)) + rule(x, y0, x, y1, subcolor, subwidth); + for (const y of rungs(y0, y1, oy, this.yunit / subdiv)) + rule(x0, y, x1, y, subcolor, subwidth); + } + const xs = rungs(x0, x1, ox, this.xunit); + const ys = rungs(y0, y1, oy, this.yunit); + for (const x of xs) + rule(x, y0, x, y1, gridcolor, gridwidth); + for (const y of ys) + rule(x0, y, x1, y, gridcolor, gridwidth); + // Grid numbers are drawn by default, as PSTricks draws them: `gridlabels` + // defaults to 10pt and only a zero or `none` turns them off. They were + // opt-in here on the grounds that PSTricks numbers an unbounded page while + // an SVG is clipped to the picture, so a grid flush with the edge would + // push them out of the viewport. That is true — the reference renders show + // PSTricks itself running off the page — but it is an argument for the + // clamping below, not for silently dropping a default the author expects. + const labels = this.gridlabels ?? 10; + if (labels === 'none' || Number(labels) === 0) + return; + const size = dimension(labels, 10); + const labelcolor = this.gridlabelcolor ?? 'black'; + const text = (s, x, y, anchor) => { + svg + .append('svg:text') + .attr('x', x).attr('y', y) + .attr('text-anchor', anchor) + .attr('font-size', size) + .attr('font-family', 'serif') + .style('fill', labelcolor) + .text(s); + }; + const round = (n) => (Math.abs(n) < 1e-9 ? 0 : Number(n.toFixed(4))); + const env = this.global || {}; + // Clamped inside the picture so a grid flush with the edge still shows its + // numbers rather than pushing them out of the viewport. + const belowY = Math.min(y1 + size + 4, (env.h ?? 0) * (env.yunit ?? 1) - 2); + const leftX = Math.max(x0 - 4, size); + for (const x of xs) + text(String(round(x / env.xunit - env.w + env.x1)), x, belowY, 'middle'); + for (const y of ys) + text(String(round(env.y1 - y / env.yunit)), leftX, y + size / 3, 'end'); + }, + psellipse(svg) { + svg + .append('svg:ellipse') + .attr('cx', this.cx) + .attr('cy', this.cy) + .attr('rx', this.rx) + .attr('ry', this.ry) + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)) + .style('stroke-width', this.linewidth) + .style('stroke-opacity', 1) + .style('fill', resolveFill(this, svg)); + }, + psbezier(svg) { + // The path stays open even when filled: PSTricks bounds the region with the + // chord back to the start but does not draw that chord, and SVG fills an + // open subpath as if closed while stroking only what was written. Closing + // it with Z would fill identically but paint a line along the chord. + const d = 'M ' + this.x1 + ' ' + this.y1 + + ' C ' + this.x2 + ' ' + this.y2 + ', ' + this.x3 + ' ' + this.y3 + ', ' + this.x4 + ' ' + this.y4; + svg + .append('svg:path') + .attr('d', d) + .style('stroke-width', this.linewidth) + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)) + .style('stroke-opacity', 1) + .style('fill', resolveFill(this, svg)); + }, + pscurve(svg) { + const d = buildCurvePath(this.data, this.endpoints ? 'endpoints' : this.closed ? 'closed' : 'open', this); + if (!d) + return; + svg + .append('svg:path') + .attr('d', d) + .style('stroke-width', this.linewidth) + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)) + .style('stroke-opacity', 1) + .style('fill', resolveFill(this, svg)); + }, + psecurve: curveRenderer, + psccurve: curveRenderer, + pswedge(svg) { + const { delta, large, sweep, full } = arcFlags(this.angleA, this.angleB); + const d = full || delta === 0 + ? fullCirclePath(this.cx, this.cy, this.r) + : 'M ' + this.cx + ' ' + this.cy + + ' L ' + this.A.x + ' ' + this.A.y + + ' A ' + this.r + ' ' + this.r + ' 0 ' + large + ' ' + sweep + + ' ' + this.B.x + ' ' + this.B.y + ' Z'; + svg + .append('svg:path') + .attr('d', d) + .style('stroke-width', this.linewidth) + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)) + .style('stroke-opacity', 1) + .style('fill', resolveFill(this, svg)); + }, + pscustom(svg) { + const filled = hasFill(this); + let d = ''; + let started = false; + (this.commands || []).forEach((cmd) => { + const data = cmd.data; + if (!data) + return; + // The canonical path vocabulary. These describe the path directly rather + // than contributing a shape, so they come first. + if (cmd.key === 'moveto') { + d += ' M ' + data.x + ' ' + data.y; + started = true; + return; + } + if (cmd.key === 'lineto') { + if (!started) { + d += 'M ' + data.x + ' ' + data.y; + started = true; + return; } - }); - Object.entries(plots || {}) - .forEach(([k, plot]) => { - if (k.match(/psplot/)) { - plot.forEach((data) => { - Object.entries(currentEnvironment || {}) - .forEach(([name, variable]) => { - data.env.variables[name] = variable; - }); - var d = data.fn.call(data.env, data.match); - d.global = {}; - Object.assign(d.global, env); - psgraph[k].call(d, svg); - }); + d += ' L ' + data.x + ' ' + data.y; + return; + } + if (cmd.key === 'curveto') { + if (!started) { + d += 'M ' + data.x1 + ' ' + data.y1; + started = true; } - if (k.match(/userline/)) { - plot.forEach((data) => { - var d = data.fn.call(data.env, data.match); - data.env.x2 = coords[0]; - data.env.y2 = coords[1]; - data.data.x2 = data.env.x2; - data.data.y2 = data.env.y2; - if (data.data.xExp2) { - data.data.x2 = d.userx2(coords); - data.data.x1 = d.userx(coords); - } - else if (data.data.xExp) { - data.data.x2 = d.userx(coords); - } - if (data.data.yExp2) { - data.data.y2 = d.usery2(coords); - data.data.y1 = d.usery(coords); - } - else if (data.data.yExp) { - data.data.y2 = d.usery(coords); - } - d.global = {}; - Object.assign(d.global, env); - Object.assign(d, data.data); - psgraph[k].call(d, svg); - }); + d += ' C ' + data.x1 + ' ' + data.y1 + ', ' + data.x2 + ' ' + data.y2 + + ', ' + data.x + ' ' + data.y; + return; + } + if (cmd.key === 'closepath') { + if (started) + d += ' Z'; + return; + } + if (cmd.key === 'psline' || cmd.key === 'userline' || cmd.key === 'psbezier') { + if (cmd.key === 'psbezier') { + if (!started) { + d += 'M ' + data.x1 + ' ' + data.y1; + started = true; + } + d += ' C ' + data.x2 + ' ' + data.y2 + ', ' + data.x3 + ' ' + data.y3 + ', ' + data.x4 + ' ' + data.y4; + return; } - }); - } - // Enhanced cleanup and RPUT processing - psgraph.processRputElements.call(this, el); + // Every point, not just the endpoint. A \psline inside a \pscustom + // continues the path through all of its coordinates; taking x2 alone + // dropped the intermediate ones — and, when the path was already open, + // dropped the line's own starting point as well, so a four-point + // zigzag came out as two segments through the wrong corners. + const pts = data.points && data.points.length >= 2 + ? data.points + : [[data.x1, data.y1], [data.x2, data.y2]]; + let from = 0; + if (!started) { + d += 'M ' + pts[0][0] + ' ' + pts[0][1]; + started = true; + from = 1; + } + for (let i = from; i < pts.length; i++) + d += ' L ' + pts[i][0] + ' ' + pts[i][1]; + } + else if (cmd.key === 'psframe') { + if (!started) { + d += 'M ' + data.x1 + ' ' + data.y1; + started = true; + } + d += ' L ' + data.x2 + ' ' + data.y1 + + ' L ' + data.x2 + ' ' + data.y2 + + ' L ' + data.x1 + ' ' + data.y2 + ' Z'; + } + else if (cmd.key === 'pspolygon' || cmd.key === 'pscurve') { + const pts = data.data || []; + if (pts.length < 2) + return; + if (!started) { + d += 'M ' + pts[0] + ' ' + pts[1]; + started = true; + } + for (let i = 2; i < pts.length; i += 2) + d += ' L ' + pts[i] + ' ' + pts[i + 1]; + d += ' Z'; + } + }); + if (!started) + return; + if (filled) + d += ' Z'; + svg + .append('svg:path') + .attr('d', d) + .style('stroke-width', this.linewidth) + // Spelled inline here rather than through the resolver, which is how this + // one site kept missing the sweeps that fixed the others. + .style('stroke', resolveStroke(this)) + .style('stroke-dasharray', dashArray(this)) + .style('stroke-linecap', dashCap(this)) + .style('stroke-opacity', 1) + .style('fill', resolveFill(this, svg)); }, processRputElements(el) { // Validate container @@ -1632,7 +5395,7 @@ const psgraph = { }; exports.default = psgraph; -},{"@latex2js/utils":20}],18:[function(require,module,exports){ +},{"@latex2js/utils":25}],22:[function(require,module,exports){ "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; @@ -1641,11 +5404,63 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.Functions = exports.Expressions = void 0; const utils_1 = require("@latex2js/utils"); const settings_1 = __importDefault(require("@latex2js/settings")); +/** + * Parse a PSTricks linewidth value: a bare number is used as-is (SVG px), + * a `pt` value is converted to px (1pt ≈ 1.333px). + */ +/** + * The unit a radius is measured in. + * + * PSTricks scales a radius by `runit` and a coordinate by `xunit`/`yunit`, so + * `\psset{xunit=2}` stretches where a circle sits without changing how big it + * is. Reading the radius through xunit made `\pscircle(0,0){1}` twice the size + * the reference draws it. `runit` shares the same default, so this only parts + * company from the old reading once a document sets the axes independently. + */ +function radiusUnit(ctx) { + const r = Number(ctx && ctx.runit); + if (isFinite(r) && r > 0) + return r; + return Number(ctx && ctx.xunit); +} +function parseLinewidth(value) { + const m = value.trim().match(/^([\d.]+)\s*(pt)?$/); + if (!m) + return 2; + return Number(m[1]) * (m[2] ? 1.333 : 1); +} +/** + * Device-space endpoints of an arc, measured from the arc's own centre. + * + * The radius is an offset from `(cx, cy)`, not from the picture origin, so the + * centre has to be added before the coordinate transform. Transforming + * `r*cos(theta)` alone places both endpoints as though every arc were centred + * on the origin — correct only for one that happens to be, which is why a pie + * at (0,0) looked right while the same wedge anywhere else collapsed to a + * spike reaching back to the origin. + * + * @param cx - centre x in picture units (empty or absent means 0) + * @param cy - centre y in picture units + * @param r - radius in picture units + * @param angleA - start angle in radians + * @param angleB - end angle in radians + * @returns the `A` and `B` endpoints in device coordinates + */ +function arcEndpoints(cx, cy, r, angleA, angleB) { + const ox = cx === undefined || cx === '' ? 0 : Number(cx); + const oy = cy === undefined || cy === '' ? 0 : Number(cy); + const radius = Number(r); + const at = (angle) => ({ + x: utils_1.X.call(this, ox + radius * Math.cos(angle)), + y: utils_1.Y.call(this, oy + radius * Math.sin(angle)) + }); + return { A: at(angleA), B: at(angleB) }; +} exports.Expressions = { pspicture: /\\begin\{pspicture\}\(\s*(.*),(.*)\s*\)\(\s*(.*),(.*)\s*\)/, - psframe: /\\psframe\(\s*(.*),(.*)\s*\)\(\s*(.*),(.*)\s*\)/, - psplot: /\\psplot(\[[^\]]*\])?\{([^\}]*)\}\{([^\}]*)\}\{([^\}]*)\}/, - psarc: new RegExp('\\\\psarc' + + psframe: /\\psframe\*?(\[[^\]]*\])?\(\s*(.*),(.*)\s*\)\(\s*(.*),(.*)\s*\)/, + psplot: /\\psplot\*?(\[[^\]]*\])?\{([^\}]*)\}\{([^\}]*)\}\{([^\}]*)\}/, + psarc: new RegExp('\\\\psarc\\*?' + utils_1.RE.options + utils_1.RE.type + utils_1.RE.coords + @@ -1653,8 +5468,8 @@ exports.Expressions = { utils_1.RE.squiggle + utils_1.RE.squiggle), pscircle: /\\pscircle.*\(\s*(.*),(.*)\s*\)\{(.*)\}/, - pspolygon: new RegExp('\\\\pspolygon' + utils_1.RE.options + '(.*)'), - psaxes: new RegExp('\\\\psaxes' + + pspolygon: new RegExp('\\\\pspolygon\\*?' + utils_1.RE.options + '(.*)'), + psaxes: new RegExp('\\\\psaxes\\*?' + utils_1.RE.options + utils_1.RE.type + utils_1.RE.coords + @@ -1667,7 +5482,7 @@ exports.Expressions = { utils_1.RE.squiggle + utils_1.RE.squiggle + utils_1.RE.squiggle), - psline: new RegExp('\\\\psline' + utils_1.RE.options + utils_1.RE.type + utils_1.RE.coords + utils_1.RE.coordsOpt), + psline: new RegExp('\\\\psline\\*?' + utils_1.RE.options + utils_1.RE.type + utils_1.RE.coords + utils_1.RE.coordsOpt + '((?:\\s*\\([^)]*\\))*)'), userline: new RegExp('\\\\userline' + utils_1.RE.options + utils_1.RE.type + @@ -1678,8 +5493,28 @@ exports.Expressions = { utils_1.RE.squiggleOpt + utils_1.RE.squiggleOpt), uservariable: new RegExp('\\\\uservariable' + utils_1.RE.options + utils_1.RE.squiggle + utils_1.RE.coords + utils_1.RE.squiggle), - rput: /\\rput\((.*),(.*)\)\{(.*)\}/, - psset: /\\psset\{(.*)\}/ + // The coordinates cannot contain a paren or the separating comma. They were + // `(.*),(.*)`, which is greedy: on `\rput(1,-2){\pscircle(0,0){0.5}}` the x + // capture ran to the comma inside the nested shape, so the placement read + // its coordinates out of the contents. + rput: /\\rput\(\s*([^,()]*),([^()]*?)\s*\)\s*\{([\s\S]*)\}/, + psset: /\\psset\{(.*)\}/, + psdots: new RegExp('\\\\psdots' + utils_1.RE.options + '(.*)'), + psgrid: new RegExp('\\\\psgrid' + utils_1.RE.options + utils_1.RE.coordsOpt + utils_1.RE.coordsOpt + utils_1.RE.coordsOpt), + psellipse: /\\psellipse.*\(\s*(.*),(.*)\s*\)\(\s*(.*),(.*)\s*\)/, + psbezier: /\\psbezier\*?(\[[^\]]*\])?\((.*),(.*)\)\((.*),(.*)\)\((.*),(.*)\)\((.*),(.*)\)/, + pscurve: new RegExp('\\\\pscurve\\*?' + utils_1.RE.options + utils_1.RE.coords + '(.*)'), + psecurve: new RegExp('\\\\psecurve\\*?' + utils_1.RE.options + utils_1.RE.coords + '(.*)'), + psccurve: new RegExp('\\\\psccurve\\*?' + utils_1.RE.options + utils_1.RE.coords + '(.*)'), + pswedge: /\\pswedge\*?(\[[^\]]*\])?\(\s*(.*),(.*)\s*\)\{(.*)\}\{(.*)\}\{(.*)\}/, + pscustom: /\\pscustom\*?(\[[^\]]*\])?\{([\s\S]*)\}/, + // The canonical \pscustom path vocabulary. Only meaningful inside one, and + // inert elsewhere because psgraph has no renderer under these names. + moveto: /\\moveto\(\s*([^,)]*),([^)]*)\s*\)/, + lineto: /\\lineto\(\s*([^,)]*),([^)]*)\s*\)/, + closepath: /\\closepath/, + curveto: /\\curveto\(\s*([^,)]*),([^)]*)\s*\)\(\s*([^,)]*),([^)]*)\s*\)\(\s*([^,)]*),([^)]*)\s*\)/, + multido: /\\multido\{([^}]*)\}\{([^}]*)\}\{([\s\S]*)\}/ }; exports.Functions = { slider(m) { @@ -1716,19 +5551,38 @@ exports.Functions = { }, psframe(m) { var obj = { - x1: utils_1.X.call(this, m[1]), - y1: utils_1.Y.call(this, m[2]), - x2: utils_1.X.call(this, m[3]), - y2: utils_1.Y.call(this, m[4]) + x1: utils_1.X.call(this, m[2]), + y1: utils_1.Y.call(this, m[3]), + x2: utils_1.X.call(this, m[4]), + y2: utils_1.Y.call(this, m[5]), + linecolor: 'black', + linestyle: 'solid', + fillstyle: 'none', + fillcolor: 'black', + linewidth: 2, + filled: /\\psframe\*/.test(m[0]) }; + if (m[1]) + Object.assign(obj, (0, utils_1.parseOptions)(m[1])); return obj; }, pscircle(m) { var obj = { cx: utils_1.X.call(this, m[1]), cy: utils_1.Y.call(this, m[2]), - r: this.xunit * m[3] + // A radius is a magnitude. PSTricks draws the same circle for a negative + // one; SVG rejects it outright, so the shape vanished with a console error. + r: Math.abs(radiusUnit(this) * Number(m[3])), + linecolor: 'black', + linestyle: 'solid', + fillstyle: 'none', + fillcolor: 'black', + linewidth: 2, + filled: /\\pscircle\*/.test(m[0]) }; + var opts = m[0].match(/\[([^\]]*)\]/); + if (opts) + Object.assign(obj, (0, utils_1.parseOptions)(opts[1])); return obj; }, psaxes(m) { @@ -1737,7 +5591,9 @@ exports.Functions = { dy: 1 * this.yunit, arrows: [0, 0], dots: [0, 0], - ticks: 'all' + ticks: 'all', + labels: 'all', + showorigin: true }; if (m[1]) { var options = (0, utils_1.parseOptions)(m[1]); @@ -1747,11 +5603,37 @@ exports.Functions = { if (options.Dy) { obj.dy = Number(options.Dy) * this.yunit; } + // `ticks` and `labels` select which axes get marks and numbers; both + // accept all / x / y / none. Dropping them meant ticks=none still drew + // ticks and labels could never be turned on. + if (options.ticks) + obj.ticks = options.ticks; + if (options.labels) + obj.labels = options.labels; + // arrowscale scales the axis arrowheads; it reaches the renderer as a + // string via parseOptions and is converted where the head is drawn. + if (options.arrowscale) + obj.arrowscale = options.arrowscale; + // showorigin=false suppresses the tick and number at the origin; the + // default is to draw them. + if (options.showorigin) + obj.showorigin = options.showorigin !== 'false'; } // arrows? var l = (0, utils_1.parseArrows)(m[2]); obj.arrows = l.arrows; obj.dots = l.dots; + // psaxes reads its options key by key rather than assigning them wholesale, + // so an `arrows=` option was dropped on the floor and an arrowed axis drew + // no head at all — and kept the tick and number the head should suppress. + if (m[1]) { + const opts = (0, utils_1.parseOptions)(m[1]); + if (opts.arrows) { + const fromOption = (0, utils_1.parseArrows)(opts.arrows); + obj.arrows = fromOption.arrows; + obj.dots = fromOption.dots; + } + } // \psaxes*[par]{arrows}(x0,y0)(x1,y1)(x2,y2) // m[1] [options] // m[2] {<->} @@ -1789,39 +5671,57 @@ exports.Functions = { var endX = utils_1.evaluate.call(this, m[3]); var data = []; var x; - // get env - var expression = ''; - Object.entries(this.variables || {}).forEach(([name, val]) => { - expression += 'var ' + name + ' = ' + val + ';'; - }); - const mathFunctions = 'var cos=Math.cos,sin=Math.sin,tan=Math.tan,atan=Math.atan,atan2=Math.atan2,exp=Math.exp,log=Math.log,sqrt=Math.sqrt,abs=Math.abs,floor=Math.floor,ceil=Math.ceil,round=Math.round,pow=Math.pow,PI=Math.PI,E=Math.E;'; - expression += mathFunctions + 'return ' + m[4] + ';'; - for (x = startX; x <= endX; x += 0.005) { - data.push(utils_1.X.call(this, x)); - try { - const evalFunc = new Function('x', expression); - const yValue = evalFunc(x); - if (yValue !== undefined && !isNaN(yValue)) { - data.push(utils_1.Y.call(this, yValue)); - } - else { - data.push(utils_1.Y.call(this, 0)); - } - } - catch (err) { - data.push(utils_1.Y.call(this, 0)); // fallback value - } - } var obj = { linecolor: 'black', linestyle: 'solid', fillstyle: 'none', fillcolor: 'none', - linewidth: 2 + linewidth: 2, + // `plotstyle=dots` marks the samples rather than joining them; dotsize is + // the marker radius, matching psdots so a document using both agrees. + plotstyle: 'line', + dotsize: '2pt 2' }; if (m[1]) Object.assign(obj, (0, utils_1.parseOptions)(m[1])); + // Sampling: honor `plotpoints=N` (number of samples); default to a + // fixed 0.005 step like the original implementation. + var step = 0.005; + var plotpoints = obj.plotpoints ? Number(obj.plotpoints) : 0; + if (plotpoints > 1) { + step = (endX - startX) / (plotpoints - 1); + } + else if (obj.plotpoints !== undefined && plotpoints < 2) { + // Fewer than two samples has no defined meaning — there is no interval + // left to step across — so the default sampling is used instead. Saying + // so beats accepting the option and quietly doing something else. + obj.plotpointsIgnored = plotpoints; + } + // Compile the plot expression once; evaluate per sample against a + // reused scope (compile-once / evaluate-many). + let compiled; + try { + compiled = (0, utils_1.parseExpression)(m[4]); + } + catch (err) { + console.warn('psplot: could not parse expression:', err.message); + obj.data = data; + return obj; + } + const scope = Object.assign({}, this.variables || {}); + for (x = startX; x <= endX + step / 2; x += step) { + data.push(utils_1.X.call(this, x)); + scope.x = x; + const yValue = compiled.evaluate(scope); + if (yValue !== undefined && !isNaN(yValue)) { + data.push(utils_1.Y.call(this, yValue)); + } + else { + data.push(utils_1.Y.call(this, 0)); + } + } obj.data = data; + (0, utils_1.normalizeArrows)(obj); return obj; }, pspolygon(m) { @@ -1845,6 +5745,7 @@ exports.Functions = { fillstyle: 'none', fillcolor: 'black', linewidth: 2, + filled: /\\pspolygon\*/.test(m[0]), data: data }; if (m[1]) @@ -1858,11 +5759,15 @@ exports.Functions = { var obj = { linecolor: 'black', linestyle: 'solid', - fillstyle: 'solid', + // PSTricks leaves every shape unfilled unless a fillstyle is + // given or the starred form is used; an unstarred \psarc is an open + // curve, not a solid black wedge. + fillstyle: 'none', fillcolor: 'black', linewidth: 2, arrows: arrows, dots: dots, + filled: /\\psarc\*/.test(m[0]), cx: utils_1.X.call(this, 0), cy: utils_1.Y.call(this, 0) }; @@ -1883,17 +5788,11 @@ exports.Functions = { obj.cy = utils_1.Y.call(this, m[4]); } // choose x units over y, no reason... - obj.r = Number(m[5]) * this.xunit; + obj.r = Math.abs(Number(m[5]) * radiusUnit(this)); obj.angleA = (Number(m[6]) * Math.PI) / 180; obj.angleB = (Number(m[7]) * Math.PI) / 180; - obj.A = { - x: utils_1.X.call(this, Number(m[5]) * Math.cos(obj.angleA)), - y: utils_1.Y.call(this, Number(m[5]) * Math.sin(obj.angleA)) - }; - obj.B = { - x: utils_1.X.call(this, Number(m[5]) * Math.cos(obj.angleB)), - y: utils_1.Y.call(this, Number(m[5]) * Math.sin(obj.angleB)) - }; + Object.assign(obj, arcEndpoints.call(this, m[3], m[4], m[5], obj.angleA, obj.angleB)); + (0, utils_1.normalizeArrows)(obj); return obj; }, psline(m) { @@ -1909,7 +5808,8 @@ exports.Functions = { fillcolor: 'black', linewidth: 2, arrows: arrows, - dots: dots + dots: dots, + filled: /\\psline\*/.test(m[0]) }; if (m[5]) { obj.x1 = utils_1.X.call(this, m[3]); @@ -1923,13 +5823,23 @@ exports.Functions = { obj.x2 = utils_1.X.call(this, m[3]); obj.y2 = utils_1.Y.call(this, m[4]); } + // \psline takes any number of points, not two. Everything past the second + // was dropped, so a polyline silently rendered as its first segment. + obj.points = [[obj.x1, obj.y1], [obj.x2, obj.y2]]; + const extra = m[8]; + if (extra) { + for (const pair of String(extra).matchAll(/\(\s*([^,()]*),([^,()]*)\s*\)/g)) { + obj.points.push([utils_1.X.call(this, pair[1]), utils_1.Y.call(this, pair[2])]); + } + } if (options) { Object.assign(obj, (0, utils_1.parseOptions)(options)); } // TODO: add regex if (typeof obj.linewidth === 'string') { - obj.linewidth = 2; + obj.linewidth = parseLinewidth(obj.linewidth); } + (0, utils_1.normalizeArrows)(obj); return obj; }, uservariable(m) { @@ -1946,26 +5856,19 @@ exports.Functions = { } var nx1 = utils_1.Xinv.call(this, coords[0]); var ny1 = utils_1.Yinv.call(this, coords[1]); - var expx1 = 'var x = ' + nx1 + ';'; - var expy1 = 'var y = ' + ny1 + ';'; - // return X.call(this, eval(expy1 + expx1 + xExp)); var obj = { name: m[2], x: utils_1.X.call(this, m[3]), y: utils_1.Y.call(this, m[4]), func: m[5], - value: (() => { - try { - const mathFunctions = 'var cos=Math.cos,sin=Math.sin,tan=Math.tan,atan=Math.atan,atan2=Math.atan2,exp=Math.exp,log=Math.log,sqrt=Math.sqrt,abs=Math.abs,floor=Math.floor,ceil=Math.ceil,round=Math.round,pow=Math.pow,PI=Math.PI,E=Math.E;'; - const evalFunc = new Function('', mathFunctions + expx1 + expy1 + 'return ' + m[5]); - return evalFunc(); - } - catch (err) { - console.warn('Error evaluating uservariable expression:', err); - return 0; - } - })() + value: 0 }; + try { + obj.value = (0, utils_1.parseExpression)(m[5]).evaluate(Object.assign({ x: nx1, y: ny1 }, this.variables || {})); + } + catch (err) { + console.warn('Error evaluating uservariable expression:', err.message); + } return obj; }, userline(m) { @@ -1975,41 +5878,40 @@ exports.Functions = { var l = (0, utils_1.parseArrows)(lineType); var arrows = l.arrows; var dots = l.dots; - var xExp = m[7]; - var yExp = m[8]; - const mathFunctions = 'var cos=Math.cos,sin=Math.sin,tan=Math.tan,atan=Math.atan,atan2=Math.atan2,exp=Math.exp,log=Math.log,sqrt=Math.sqrt,abs=Math.abs,floor=Math.floor,ceil=Math.ceil,round=Math.round,pow=Math.pow,PI=Math.PI,E=Math.E;'; - if (xExp) - xExp = mathFunctions + xExp.replace(/^\{/, '').replace(/\}$/, ''); - if (yExp) - yExp = mathFunctions + yExp.replace(/^\{/, '').replace(/\}$/, ''); - var xExp2 = m[9]; - var yExp2 = m[10]; - if (xExp2) - xExp2 = mathFunctions + xExp2.replace(/^\{/, '').replace(/\}$/, ''); - if (yExp2) - yExp2 = mathFunctions + yExp2.replace(/^\{/, '').replace(/\}$/, ''); - var expression = ''; - Object.entries(this.variables || {}).forEach(([name, val]) => { - expression += 'var ' + name + ' = ' + val + ';'; - }); + // Compile the interactive head/tail expressions once; each mousemove just + // re-evaluates them against a fresh {x, y} scope (compile-once). + const stripBraces = (s) => (s ? s.replace(/^\{/, '').replace(/\}$/, '').trim() : null); + const compileOpt = (src) => { + if (!src) + return null; + try { + return (0, utils_1.parseExpression)(src); + } + catch (err) { + console.warn('userline: could not parse expression:', err.message); + return null; + } + }; + const xExp = compileOpt(stripBraces(m[7])); + const yExp = compileOpt(stripBraces(m[8])); + const xExp2 = compileOpt(stripBraces(m[9])); + const yExp2 = compileOpt(stripBraces(m[10])); + const variables = this.variables || {}; + const evalAt = (compiled, x, y) => compiled.evaluate(Object.assign({ x: x, y: y }, variables)); var obj = { x1: utils_1.X.call(this, m[3]), y1: utils_1.Y.call(this, m[4]), x2: utils_1.X.call(this, m[5]), y2: utils_1.Y.call(this, m[6]), - xExp: xExp, - yExp: yExp, - xExp2: xExp2, - yExp2: yExp2, + xExp: m[7], + yExp: m[8], + xExp2: m[9], + yExp2: m[10], userx: (coords) => { var nx1 = utils_1.Xinv.call(this, coords[0]); var ny1 = utils_1.Yinv.call(this, coords[1]); - var expx1 = 'var x = ' + nx1 + ';'; - var expy1 = 'var y = ' + ny1 + ';'; try { - const cleanExp = xExp ? xExp.replace(/^var cos=Math\.cos[^;]*;/, '') : '0'; - const evalFunc = new Function('', mathFunctions + expression + expy1 + expx1 + 'return (' + cleanExp + ')'); - return utils_1.X.call(this, evalFunc()); + return utils_1.X.call(this, xExp ? evalAt(xExp, nx1, ny1) : 0); } catch (err) { console.warn('Error evaluating userx expression:', err); @@ -2019,12 +5921,8 @@ exports.Functions = { usery: (coords) => { var nx2 = utils_1.Xinv.call(this, coords[0]); var ny2 = utils_1.Yinv.call(this, coords[1]); - var expx2 = 'var x = ' + nx2 + ';'; - var expy2 = 'var y = ' + ny2 + ';'; try { - const cleanExp = yExp ? yExp.replace(/^var cos=Math\.cos[^;]*;/, '') : '0'; - const evalFunc = new Function('', mathFunctions + expression + expy2 + expx2 + 'return (' + cleanExp + ')'); - return utils_1.Y.call(this, evalFunc()); + return utils_1.Y.call(this, yExp ? evalAt(yExp, nx2, ny2) : 0); } catch (err) { console.warn('Error evaluating usery expression:', err); @@ -2034,12 +5932,8 @@ exports.Functions = { userx2: (coords) => { var nx3 = utils_1.Xinv.call(this, coords[0]); var ny3 = utils_1.Yinv.call(this, coords[1]); - var expx3 = 'var x = ' + nx3 + ';'; - var expy3 = 'var y = ' + ny3 + ';'; try { - const cleanExp = xExp2 ? xExp2.replace(/^var cos=Math\.cos[^;]*;/, '') : '0'; - const evalFunc = new Function('', mathFunctions + expression + expy3 + expx3 + 'return (' + cleanExp + ')'); - return utils_1.X.call(this, evalFunc()); + return utils_1.X.call(this, xExp2 ? evalAt(xExp2, nx3, ny3) : 0); } catch (err) { console.warn('Error evaluating userx2 expression:', err); @@ -2049,12 +5943,8 @@ exports.Functions = { usery2: (coords) => { var nx4 = utils_1.Xinv.call(this, coords[0]); var ny4 = utils_1.Yinv.call(this, coords[1]); - var expx4 = 'var x = ' + nx4 + ';'; - var expy4 = 'var y = ' + ny4 + ';'; try { - const cleanExp = yExp2 ? yExp2.replace(/^var cos=Math\.cos[^;]*;/, '') : '0'; - const evalFunc = new Function('', mathFunctions + expression + expy4 + expx4 + 'return (' + cleanExp + ')'); - return utils_1.Y.call(this, evalFunc()); + return utils_1.Y.call(this, yExp2 ? evalAt(yExp2, nx4, ny4) : 0); } catch (err) { console.warn('Error evaluating usery2 expression:', err); @@ -2074,8 +5964,9 @@ exports.Functions = { } // TODO: add regex if (typeof obj.linewidth === 'string') { - obj.linewidth = 2; + obj.linewidth = parseLinewidth(obj.linewidth); } + (0, utils_1.normalizeArrows)(obj); return obj; }, rput(m) { @@ -2085,33 +5976,263 @@ exports.Functions = { text: m[3] }; }, + /** + * `\psset` declares defaults that every later command inherits. + * + * Every key is kept, not only the nine Settings knows about. Those nine need + * conversion — units become numbers, the dialect is canonicalized — and the + * rest are style defaults a shape reads in place of its own hardcoded one. + * Dropping them is why `\psset{linewidth=2pt,linestyle=dashed}` drew a thin + * solid line: the keys parsed, matched nothing, and were discarded. + */ psset(m) { - const pairs = m[1].split(',').map((pair) => pair.split('=')); const obj = {}; - pairs.forEach((pair) => { - const key = pair[0]; - const value = pair[1]; + if (!m || !m[1]) + return obj; + // parseOptions rather than a bare split, so a colour here resolves the + // same way it would inside a command's own brackets. + const declared = (0, utils_1.parseOptions)(m[1]); + Object.entries(declared).forEach(([key, value]) => { + let converted = false; Object.keys(settings_1.default.Expressions).forEach((setting) => { const exp = settings_1.default.Expressions[setting]; if (key.match(exp)) { settings_1.default.Functions[setting](obj, value); + converted = true; } }); + if (!converted) + obj[key] = value; }); return obj; + }, + psdots(m) { + var obj = { + linecolor: 'black', + dotstyle: 'dot', + // PSTricks reads `dotsize= `: the diameter is + // dim + factor x linewidth, so a thicker pen draws a bigger dot. + dotsize: '2pt 2', + linewidth: 0.8 * 1.333, + data: parseCoordList.call(this, m[2]) + }; + if (m[1]) + Object.assign(obj, (0, utils_1.parseOptions)(m[1])); + return obj; + }, + psgrid(m) { + var obj = { + linecolor: 'black', + linestyle: 'solid', + linewidth: 0.5, + // PSTricks grid defaults: a heavier line on the unit, five finer + // subdivisions between, and the coordinate numbered along two edges. + gridcolor: 'black', + gridwidth: '0.8pt', + subgriddiv: 5, + subgridcolor: 'gray', + subgridwidth: '0.4pt', + gridlabelcolor: 'black' + }; + if (m[1]) + Object.assign(obj, (0, utils_1.parseOptions)(m[1])); + // \psgrid[opts](x0,y0)(x1,y1) — defaults to the whole pspicture bounds. + // coordsOpt outer groups: m[2]/m[5]/m[8] = '(x,y)' strings, m[3],m[4] etc. + var has0 = m[3] !== undefined; + var has1 = m[6] !== undefined; + var x0 = has0 ? utils_1.X.call(this, m[3]) : utils_1.X.call(this, this.x0); + var y0 = has0 ? utils_1.Y.call(this, m[4]) : utils_1.Y.call(this, this.y0); + var x1 = has1 ? utils_1.X.call(this, m[6]) : utils_1.X.call(this, this.x1); + var y1 = has1 ? utils_1.Y.call(this, m[7]) : utils_1.Y.call(this, this.y1); + obj.x0 = Math.min(x0, x1); + obj.y0 = Math.min(y0, y1); + obj.x1 = Math.max(x0, x1); + obj.y1 = Math.max(y0, y1); + obj.xunit = this.xunit; + obj.yunit = this.yunit; + // The renderer numbers each line, which needs the picture coordinate the + // device position stands for. + obj.originX = utils_1.X.call(this, 0); + obj.originY = utils_1.Y.call(this, 0); + return obj; + }, + psellipse(m) { + var obj = { + linecolor: 'black', + linestyle: 'solid', + fillstyle: 'none', + fillcolor: 'black', + linewidth: 2, + filled: /\\psellipse\*/.test(m[0]) + }; + var opts = m[0].match(/\[([^\]]*)\]/); + if (opts) + Object.assign(obj, (0, utils_1.parseOptions)(opts[1])); + obj.cx = utils_1.X.call(this, m[1]); + obj.cy = utils_1.Y.call(this, m[2]); + obj.rx = Math.abs(Number(m[3])) * this.xunit; + obj.ry = Math.abs(Number(m[4])) * this.yunit; + return obj; + }, + psbezier(m) { + var obj = { + linecolor: 'black', + linestyle: 'solid', + fillstyle: 'none', + fillcolor: 'black', + linewidth: 2, + filled: /\\psbezier\*/.test(m[0]) + }; + if (m[1]) + Object.assign(obj, (0, utils_1.parseOptions)(m[1])); + obj.x1 = utils_1.X.call(this, m[2]); + obj.y1 = utils_1.Y.call(this, m[3]); + obj.x2 = utils_1.X.call(this, m[4]); + obj.y2 = utils_1.Y.call(this, m[5]); + obj.x3 = utils_1.X.call(this, m[6]); + obj.y3 = utils_1.Y.call(this, m[7]); + obj.x4 = utils_1.X.call(this, m[8]); + obj.y4 = utils_1.Y.call(this, m[9]); + return obj; + }, + pscurve(m) { + var obj = { + linecolor: 'black', + linestyle: 'solid', + fillstyle: 'none', + fillcolor: 'black', + linewidth: 2, + filled: /\\ps[ce]?curve\*/.test(m[0]), + // Only psccurve wraps. psecurve is an open curve whose first and last + // points are tangent controls rather than points it passes through. + closed: /\\psccurve/.test(m[0]), + endpoints: /\\psecurve/.test(m[0]) + }; + if (m[1]) + Object.assign(obj, (0, utils_1.parseOptions)(m[1])); + // first point is captured separately (m[2], m[3]); the rest follow + obj.data = [utils_1.X.call(this, m[2]), utils_1.Y.call(this, m[3])].concat(parseCoordList.call(this, m[4] || '')); + return obj; + }, + psecurve(m) { + return exports.Functions.pscurve.call(this, m); + }, + psccurve(m) { + return exports.Functions.pscurve.call(this, m); + }, + pswedge(m) { + var obj = { + linecolor: 'black', + linestyle: 'solid', + // PSTricks leaves every shape unfilled unless a fillstyle is + // given or the starred form is used; an unstarred \psarc is an open + // curve, not a solid black wedge. + fillstyle: 'none', + fillcolor: 'black', + linewidth: 2, + filled: /\\pswedge\*/.test(m[0]) + }; + if (m[1]) + Object.assign(obj, (0, utils_1.parseOptions)(m[1])); + obj.cx = utils_1.X.call(this, m[2]); + obj.cy = utils_1.Y.call(this, m[3]); + obj.r = Math.abs(Number(m[4]) * radiusUnit(this)); + obj.angleA = (Number(m[5]) * Math.PI) / 180; + obj.angleB = (Number(m[6]) * Math.PI) / 180; + Object.assign(obj, arcEndpoints.call(this, m[2], m[3], m[4], obj.angleA, obj.angleB)); + return obj; + }, + pscustom(m) { + var obj = { + linecolor: 'black', + linestyle: 'solid', + fillstyle: 'none', + fillcolor: 'black', + linewidth: 2, + filled: /\\pscustom\*/.test(m[0]), + body: m[2] + }; + if (m[1]) + Object.assign(obj, (0, utils_1.parseOptions)(m[1])); + return obj; + }, + /** `\moveto(x,y)` — starts a new subpath inside \pscustom. */ + moveto(m) { + return { x: utils_1.X.call(this, m[1]), y: utils_1.Y.call(this, m[2]) }; + }, + /** `\lineto(x,y)` — a straight segment inside \pscustom. */ + lineto(m) { + return { x: utils_1.X.call(this, m[1]), y: utils_1.Y.call(this, m[2]) }; + }, + /** `\closepath` — closes the current subpath. */ + closepath() { + return { close: true }; + }, + /** `\curveto(c1)(c2)(end)` — a cubic segment inside \pscustom. */ + curveto(m) { + return { + x1: utils_1.X.call(this, m[1]), y1: utils_1.Y.call(this, m[2]), + x2: utils_1.X.call(this, m[3]), y2: utils_1.Y.call(this, m[4]), + x: utils_1.X.call(this, m[5]), y: utils_1.Y.call(this, m[6]) + }; + }, + multido(m) { + var spec = m[1] || ''; + var varMatch = spec.match(/\\([a-zA-Z@]+)\s*=\s*([\d.+-]+)\s*\+\s*([\d.+-]+)/); + return { + variable: varMatch ? varMatch[1] : null, + start: varMatch ? Number(varMatch[2]) : 0, + step: varMatch ? Number(varMatch[3]) : 1, + count: Number(m[2]), + body: m[3] + }; } }; +/** + * Parse a coordinate list like `(0,0)(1,1)(2,2)` into a flat + * [x0,y0,x1,y1,...] pixel array. + */ +function parseCoordList(coords) { + var data = []; + var re = new RegExp(utils_1.RE.coords, 'g'); + var m; + while ((m = re.exec(coords)) !== null) { + data.push(utils_1.X.call(this, m[1])); + data.push(utils_1.Y.call(this, m[2])); + } + return data; +} exports.default = { Expressions: exports.Expressions, Functions: exports.Functions }; -},{"@latex2js/settings":19,"@latex2js/utils":20}],19:[function(require,module,exports){ +},{"@latex2js/settings":23,"@latex2js/utils":25}],23:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Functions = exports.Expressions = void 0; +exports.normalizeDialect = normalizeDialect; const utils_1 = require("@latex2js/utils"); +/** + * Canonicalizes a declared dialect name. + * + * `mathapedia` is accepted as an alias for `latex2js`: the dialect belongs to + * the renderer, while Mathapedia is a product built on it, so one canonical + * name keeps comparisons simple without making authors learn which to write. + * + * @param value - the declared name, in any case + * @returns the canonical dialect, or null when the name is not one we know + */ +function normalizeDialect(value) { + const v = String(value ?? '').trim().toLowerCase(); + if (v === 'pstricks') + return 'pstricks'; + if (v === 'latex2js' || v === 'mathapedia') + return 'latex2js'; + return null; +} exports.Expressions = { + dialect: /^dialect$/, fillcolor: /^fillcolor$/, fillstyle: /^fillstyle$/, linecolor: /^linecolor$/, @@ -2122,6 +6243,17 @@ exports.Expressions = { yunit: /^yunit/ }; exports.Functions = { + /** + * Which language the document is written in. + * + * `pstricks` is the specification; `latex2js` (alias `mathapedia`) is this + * project's superset — the interactive macros, infix plot bodies, natural-log + * `log`, starred shapes honouring `fillcolor`. Declaring it is what makes the + * extensions visible instead of indistinguishable from a bug. + */ + dialect(o, v) { + o.dialect = normalizeDialect(v); + }, fillcolor(o, v) { o.fillcolor = v; }, @@ -2159,22 +6291,355 @@ exports.default = { Functions: exports.Functions }; -},{"@latex2js/utils":20}],20:[function(require,module,exports){ +},{"@latex2js/utils":25}],24:[function(require,module,exports){ +"use strict"; +/** + * Algebraic expression parser + evaluator for PSTricks-style math. + * + * PSTricks `algebraic` expressions are NOT JavaScript: they use `^` for + * power, allow implicit multiplication (`2x`, `2(x+1)`, `2sin(x)`), and rely + * on bare math function names (`cos(x)`). This module parses an expression + * once into an AST and compiles it to a JavaScript closure that can be + * evaluated cheaply many times with a variable scope — exactly the + * compile-once / evaluate-many pattern the interactive plot and userline + * paths need. + * + * Supported syntax: + * numbers, identifiers (variables), arithmetic + - * / ^, + * unary minus/plus, implicit multiplication, parentheses, + * function calls (cos, sin, tan, atan, atan2, pow, sqrt, abs, exp, ln, + * log, floor, ceil, round, min, max, ...), comparisons (< > <= >= == !=), + * and ternary conditionals (cond ? a : b). + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MATH_CONSTANTS = exports.MATH_FUNCTIONS = exports.ExpressionError = void 0; +exports.parseExpression = parseExpression; +class ExpressionError extends Error { + constructor(message, position) { + // position is a 0-based offset; compute 1-based line/column lazily + super(message); + this.name = 'ExpressionError'; + this.position = position; + this.line = 0; + this.column = 0; + } +} +exports.ExpressionError = ExpressionError; +const OPS = ['<=', '>=', '==', '!=', '<', '>', '?', ':', '+', '-', '*', '/', '^', ',']; +const PARENS = new Set(['(', ')']); +function tokenize(source) { + const tokens = []; + let i = 0; + const n = source.length; + const numberRe = /^\d*\.?\d+(?:[eE][+-]?\d+)?/; + const identRe = /^[a-zA-Z_][a-zA-Z0-9_]*/; + while (i < n) { + const ch = source[i]; + if (/\s/.test(ch)) { + i++; + continue; + } + // Unicode pi + if (ch === 'π') { + tokens.push({ type: 'ident', value: 'π', pos: i }); + i++; + continue; + } + if (ch === '(' || ch === ')') { + tokens.push({ type: 'paren', value: ch, pos: i }); + i++; + continue; + } + const num = source.slice(i).match(numberRe); + if (num) { + tokens.push({ type: 'number', value: num[0], pos: i }); + i += num[0].length; + continue; + } + const ident = source.slice(i).match(identRe); + if (ident) { + tokens.push({ type: 'ident', value: ident[0], pos: i }); + i += ident[0].length; + continue; + } + const op = OPS.find((o) => source.startsWith(o, i)); + if (op) { + tokens.push({ type: op === '(' || op === ')' ? 'paren' : 'op', value: op, pos: i }); + i += op.length; + continue; + } + throw new ExpressionError(`unexpected character '${ch}'`, i); + } + tokens.push({ type: 'eof', value: '', pos: n }); + return tokens; +} +class Parser { + constructor(source) { + this.source = source; + this.index = 0; + this.tokens = tokenize(source); + if (this.tokens.length <= 1) { + throw new ExpressionError('empty expression', 0); + } + } + peek() { + return this.tokens[this.index]; + } + next() { + return this.tokens[this.index++]; + } + expect(value) { + const t = this.peek(); + if (t.value !== value) { + throw new ExpressionError(`expected '${value}' but found '${t.value || 'end of input'}'`, t.pos); + } + return this.next(); + } + parse() { + const node = this.parseTernary(); + const t = this.peek(); + if (t.type !== 'eof') { + throw new ExpressionError(`unexpected '${t.value}'`, t.pos); + } + return node; + } + parseTernary() { + const cond = this.parseComparison(); + if (this.peek().value === '?') { + this.next(); + const then = this.parseTernary(); + this.expect(':'); + const els = this.parseTernary(); + return { type: 'ternary', cond, then, els }; + } + return cond; + } + parseComparison() { + let left = this.parseAdditive(); + for (;;) { + const op = this.peek().value; + if (op === '<' || op === '>' || op === '<=' || op === '>=' || op === '==' || op === '!=') { + this.next(); + const right = this.parseAdditive(); + left = { type: 'binary', op, left, right }; + } + else { + return left; + } + } + } + parseAdditive() { + let left = this.parseMultiplicative(); + for (;;) { + const op = this.peek().value; + if (op === '+' || op === '-') { + this.next(); + const right = this.parseMultiplicative(); + left = { type: 'binary', op, left, right }; + } + else { + return left; + } + } + } + parseMultiplicative() { + let left = this.parseUnary(); + for (;;) { + const op = this.peek().value; + if (op === '*' || op === '/') { + this.next(); + const right = this.parseUnary(); + left = { type: 'binary', op, left, right }; + } + else if (this.isImplicitStart(this.peek())) { + // implicit multiplication: 2x, 2(x+1), (x+1)(x+2), 2sin(x) + const right = this.parseUnary(); + left = { type: 'binary', op: '*', left, right }; + } + else { + return left; + } + } + } + parseUnary() { + const op = this.peek().value; + if (op === '-' || op === '+') { + this.next(); + return { type: 'unary', op, operand: this.parseUnary() }; + } + return this.parsePower(); + } + parsePower() { + const left = this.parsePrimary(); + if (this.peek().value === '^') { + this.next(); + const right = this.parseUnary(); // right-associative, binds tighter on the right + return { type: 'binary', op: '^', left, right }; + } + return left; + } + parsePrimary() { + const t = this.peek(); + if (t.type === 'number') { + this.next(); + return { type: 'number', value: t.value }; + } + if (t.type === 'ident') { + this.next(); + // a known math function followed by '(' is a function call + if (this.peek().value === '(' && exports.MATH_FUNCTIONS.hasOwnProperty(t.value)) { + this.next(); // consume '(' + const args = []; + if (this.peek().value !== ')') { + args.push(this.parseTernary()); + while (this.peek().value === ',') { + this.next(); + args.push(this.parseTernary()); + } + } + this.expect(')'); + return { type: 'call', name: t.value, args }; + } + return { type: 'var', name: t.value }; + } + if (t.value === '(') { + this.next(); + const node = this.parseTernary(); + this.expect(')'); + return node; + } + throw new ExpressionError(`unexpected '${t.value || 'end of input'}' in expression`, t.pos); + } + /** A token that can start an implicit multiplication operand. */ + isImplicitStart(t) { + return t.type === 'number' || t.type === 'ident' || t.value === '('; + } +} +// --------------------------------------------------------------------------- +// Compile AST → JS closure +// --------------------------------------------------------------------------- +exports.MATH_FUNCTIONS = { + cos: 'Math.cos', + sin: 'Math.sin', + tan: 'Math.tan', + atan: 'Math.atan', + atan2: 'Math.atan2', + asin: 'Math.asin', + acos: 'Math.acos', + exp: 'Math.exp', + ln: 'Math.log', + log: 'Math.log', + log10: 'Math.log10', + sqrt: 'Math.sqrt', + cbrt: 'Math.cbrt', + abs: 'Math.abs', + sign: 'Math.sign', + floor: 'Math.floor', + ceil: 'Math.ceil', + round: 'Math.round', + pow: 'Math.pow', + min: 'Math.min', + max: 'Math.max', + sinh: 'Math.sinh', + cosh: 'Math.cosh', + tanh: 'Math.tanh', +}; +exports.MATH_CONSTANTS = { + pi: 'Math.PI', + π: 'Math.PI', + PI: 'Math.PI', + // pst-plot's own spelling, used by \psplot expressions. + Pi: 'Math.PI', + E: 'Math.E', +}; +function compileNode(node, variableNames) { + switch (node.type) { + case 'number': + return node.value; + case 'var': { + if (exports.MATH_CONSTANTS.hasOwnProperty(node.name)) { + return exports.MATH_CONSTANTS[node.name]; + } + variableNames.add(node.name); + return 'v.' + node.name; + } + case 'call': { + const target = exports.MATH_FUNCTIONS.hasOwnProperty(node.name) + ? exports.MATH_FUNCTIONS[node.name] + : '(v.' + node.name + ')'; + return target + '(' + node.args.map((a) => compileNode(a, variableNames)).join(',') + ')'; + } + case 'unary': + return '(' + node.op + compileNode(node.operand, variableNames) + ')'; + case 'binary': { + const op = node.op === '^' ? '**' : node.op; + return '(' + compileNode(node.left, variableNames) + op + compileNode(node.right, variableNames) + ')'; + } + case 'ternary': + return ('(' + + compileNode(node.cond, variableNames) + + '?' + + compileNode(node.then, variableNames) + + ':' + + compileNode(node.els, variableNames) + + ')'); + default: + throw new Error('unknown node type ' + node.type); + } +} +/** + * Parse an algebraic expression and compile it to an evaluable closure. + * Throws ExpressionError with a character position on invalid syntax. + */ +function parseExpression(source) { + const trimmed = source.trim(); + if (!trimmed) { + throw new ExpressionError('empty expression', 0); + } + const parser = new Parser(trimmed); + const ast = parser.parse(); + const variableNames = new Set(); + const js = compileNode(ast, variableNames); + let fn; + try { + // eslint-disable-next-line no-new-func + fn = new Function('v', 'return (' + js + ');'); + } + catch (err) { + throw new ExpressionError('could not compile expression: ' + err.message, 0); + } + return { + source: trimmed, + toJS: () => js, + variables: () => Array.from(variableNames), + evaluate: (scope) => fn(scope || {}), + }; +} + +},{}],25:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.select = exports.SVGSelection = exports.dotType = exports.arrowType = exports.Yinv = exports.Y = exports.Xinv = exports.X = exports.evaluate = exports.parseArrows = exports.parseOptions = exports.RE = exports.convertUnits = exports.matchrepl = exports.simplerepl = void 0; +exports.MATH_CONSTANTS = exports.MATH_FUNCTIONS = exports.ExpressionError = exports.parseExpression = exports.select = exports.SVGSelection = exports.dotType = exports.arrowType = exports.Yinv = exports.Y = exports.Xinv = exports.X = exports.evaluate = exports.normalizeArrows = exports.parseArrows = exports.parseOptions = exports.resolveColor = exports.defineColor = exports.resetDefinedColors = exports.RE = exports.convertUnits = exports.matchrepl = exports.simplerepl = void 0; +const expression_1 = require("./expression"); const simplerepl = function (regex, replace) { return function (_m, contents) { return contents.replace(regex, replace); }; }; exports.simplerepl = simplerepl; +/** + * Builds a text transform that rewrites each match through `callback`. + * + * The callback is invoked with the same receiver the transform was called with, + * so a transform that needs document state — section numbering, say — can reach + * it. Callbacks that do not care simply ignore `this`. + */ const matchrepl = function (regex, callback) { return function (m, contents) { if (Array.isArray(m)) { m.forEach((match) => { var m2 = match.match(regex); - contents = contents.replace(m2.input, callback(m2)); + contents = contents.replace(m2.input, callback.call(this, m2)); }); } return contents; @@ -2209,6 +6674,142 @@ exports.RE = { coordsOpt: '(\\(\\s*([^\\)]*),([^\\)]*)\\s*\\))?', coords: '\\(\\s*([^\\)]*),([^\\)]*)\\s*\\)' }; +/** Option keys whose value names a colour. */ +const COLOR_KEYS = ['linecolor', 'fillcolor', 'hatchcolor', 'gridcolor', 'bordercolor', 'shadowcolor', 'labelcolor']; +/** + * xcolor's base colour set, as RGB triples, transcribed from the + * `\definecolorset` blocks in xcolor.sty. + * + * Nine of these are not what CSS means by the same word, and the divergence is + * not subtle: xcolor's `green` is pure (0,1,0) while CSS `green` is the much + * darker #008000, and `purple`, `violet`, `lime`, `orange` and `brown` all + * name different colours in the two vocabularies. Half of this table used to + * hold the CSS values under the xcolor names, so a tint mixed toward the wrong + * colour, and a plain name was handed to the browser and read as CSS. + */ +const BASE_COLORS = { + red: [255, 0, 0], green: [0, 255, 0], blue: [0, 0, 255], + cyan: [0, 255, 255], magenta: [255, 0, 255], yellow: [255, 255, 0], + black: [0, 0, 0], white: [255, 255, 255], + gray: [128, 128, 128], grey: [128, 128, 128], + darkgray: [64, 64, 64], lightgray: [191, 191, 191], + brown: [191, 128, 64], lime: [191, 255, 0], orange: [255, 128, 0], + pink: [255, 191, 191], purple: [191, 0, 64], teal: [0, 128, 128], + violet: [128, 0, 128], olive: [128, 128, 0], +}; +/** + * Colours the document defined for itself with `\definecolor`. + * + * Kept apart from the xcolor base set so a document can shadow a built-in name + * — which is how a page written against browser colours can keep the exact + * shade it wants while staying valid LaTeX, instead of relying on a name + * xcolor never defined. + */ +const DEFINED_COLORS = {}; +/** Clears the document-defined colours. Called once per parse. */ +const resetDefinedColors = function () { + for (const name of Object.keys(DEFINED_COLORS)) + delete DEFINED_COLORS[name]; +}; +exports.resetDefinedColors = resetDefinedColors; +const clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n))); +/** + * Records a `\definecolor{name}{model}{spec}`. + * + * The models are xcolor's: `rgb` and `cmyk` take fractions, `RGB` takes + * 0-255, `gray` a single fraction, and `HTML` six hex digits. + * + * @param name - the colour's name + * @param model - the colour model the spec is written in + * @param spec - the model's components, comma separated + * @returns true when the definition was understood + */ +const defineColor = function (name, model, spec) { + const key = String(name ?? '').trim().toLowerCase(); + if (!key) + return false; + const parts = String(spec ?? '').split(',').map((p) => Number(p.trim())); + const m = String(model ?? '').trim(); + if (m === 'rgb' && parts.length >= 3 && parts.every(isFinite)) { + DEFINED_COLORS[key] = [clamp255(parts[0] * 255), clamp255(parts[1] * 255), clamp255(parts[2] * 255)]; + return true; + } + if (m === 'RGB' && parts.length >= 3 && parts.every(isFinite)) { + DEFINED_COLORS[key] = [clamp255(parts[0]), clamp255(parts[1]), clamp255(parts[2])]; + return true; + } + if (m === 'gray' && parts.length >= 1 && isFinite(parts[0])) { + const g = clamp255(parts[0] * 255); + DEFINED_COLORS[key] = [g, g, g]; + return true; + } + if (m === 'cmyk' && parts.length >= 4 && parts.every(isFinite)) { + const [c, y2, y3, k] = parts; + DEFINED_COLORS[key] = [ + clamp255(255 * (1 - Math.min(1, c + k))), + clamp255(255 * (1 - Math.min(1, y2 + k))), + clamp255(255 * (1 - Math.min(1, y3 + k))), + ]; + return true; + } + if (m === 'HTML') { + const hex = String(spec ?? '').trim().replace(/^#/, ''); + if (/^[0-9a-fA-F]{6}$/.test(hex)) { + DEFINED_COLORS[key] = [ + parseInt(hex.slice(0, 2), 16), + parseInt(hex.slice(2, 4), 16), + parseInt(hex.slice(4, 6), 16), + ]; + return true; + } + } + return false; +}; +exports.defineColor = defineColor; +/** + * Resolves an xcolor tint expression to a CSS colour. + * + * `gray!40` means forty percent gray against white, and `gray!40!red` mixes + * against red instead. A browser cannot read either, and an unparsable fill + * silently falls back to black — which is how a light grey plane rendered as + * a solid black one. + * + * @param value - a colour name, optionally with `!` mix terms + * @returns a CSS colour; names without a mix term are returned untouched + */ +const resolveColor = function (value) { + const parts = String(value).split('!').map((p) => p.trim()); + // A document's own \definecolor wins over the built-in of the same name, + // as it does in xcolor. + const rgb = (name) => DEFINED_COLORS[name.toLowerCase()] ?? BASE_COLORS[name.toLowerCase()] ?? null; + // A plain name resolves too. Nine of xcolor's base colours name a different + // colour in CSS, so handing `green` straight to the browser drew the dark + // #008000 where the document asks for pure green. + if (parts.length < 2) { + const plain = rgb(parts[0]); + return plain ? 'rgb(' + plain[0] + ',' + plain[1] + ',' + plain[2] + ')' : value; + } + let current = rgb(parts[0]); + if (!current) + return value; + for (let i = 1; i < parts.length; i += 2) { + const pct = Number(parts[i]); + if (!isFinite(pct)) + return value; + // An omitted second operand mixes against white, as xcolor does. + const against = parts[i + 1] ? rgb(parts[i + 1]) : [255, 255, 255]; + if (!against) + return value; + const w = Math.max(0, Math.min(100, pct)) / 100; + current = [ + Math.round(current[0] * w + against[0] * (1 - w)), + Math.round(current[1] * w + against[1] * (1 - w)), + Math.round(current[2] * w + against[2] * (1 - w)), + ]; + } + return 'rgb(' + current[0] + ',' + current[1] + ',' + current[2] + ')'; +}; +exports.resolveColor = resolveColor; // OPTIONS // converts [showorigin=false,labels=none, Dx=3.14] to {showorigin: 'false', labels: 'none', Dx: '3.14'} const parseOptions = function (opts) { @@ -2218,7 +6819,9 @@ const parseOptions = function (opts) { all.forEach((option) => { var kv = option.split('='); if (kv.length == 2) { - obj[kv[0].trim()] = kv[1].trim(); + const key = kv[0].trim(); + const value = kv[1].trim(); + obj[key] = COLOR_KEYS.indexOf(key) === -1 ? value : (0, exports.resolveColor)(value); } }); return obj; @@ -2229,7 +6832,10 @@ const parseArrows = function (m) { var arrows = [0, 0]; var dots = [0, 0]; if (lineType) { - var type = lineType.match(/\{([^\-]*)?\-([^\-]*)?\}/); + // The braces are optional. PSTricks accepts the same specification as an + // option — `[arrows=->]` — and requiring `{->}` meant the option form + // matched nothing here, so it was left on the shape as a bare string. + var type = lineType.match(/\{?([^\-{}]*)\-([^\-{}]*)\}?/); if (type) { if (type[1]) { // check starting point @@ -2257,6 +6863,30 @@ const parseArrows = function (m) { }; }; exports.parseArrows = parseArrows; +/** + * Turns an `arrows` option back into the pair of flags a renderer reads. + * + * A shape's arrow specification arrives two ways: as the `{->}` group in its + * own syntax, which its parse function reads, and as an `arrows=->` option, + * which `parseOptions` hands over as a plain string. The string then + * overwrote the parsed pair — and since `'->'[0]` is `'-'`, which is truthy, + * every renderer testing `arrows[0]` drew a head at BOTH ends regardless of + * the direction asked for, while `*-*` drew two arrowheads where PSTricks + * draws two discs. + * + * @param obj - a parsed command, normalized in place + */ +const normalizeArrows = function (obj) { + if (!obj || typeof obj.arrows !== 'string') + return; + const parsed = (0, exports.parseArrows)(obj.arrows); + obj.arrows = parsed.arrows; + // Only take the dots when this specification actually names any, so an + // `arrows=` option cannot clear dots the `{*-*}` form already set. + if (parsed.dots[0] || parsed.dots[1]) + obj.dots = parsed.dots; +}; +exports.normalizeArrows = normalizeArrows; // export const evaluate = function (this: any, exp: string) { // var num = Number(exp); // if (isNaN(num)) { @@ -2277,53 +6907,52 @@ const evaluate = function (exp) { if (!isNaN(num)) return num; this.variables = this.variables || {}; - const mathKeys = Object.keys(Math); - const varKeys = Object.keys(this.variables); - const allKeys = [...mathKeys, ...varKeys]; - const allValues = [ - ...mathKeys.map(k => Math[k]), - ...varKeys.map(k => this.variables[k]) - ]; try { - // @ts-ignore - const fn = new Function(...allKeys, `return (${exp});`); - return fn(...allValues); + return getCompiled(exp).evaluate(this.variables); } catch (e) { - console.warn('Evaluation error:', e); + console.warn('Evaluation error:', e.message); return NaN; } }; exports.evaluate = evaluate; +// Small bounded cache so repeated identical expressions (e.g. plot bounds, +// slider-driven re-evaluation) skip re-parsing entirely. +const expressionCache = new Map(); +const EXPRESSION_CACHE_MAX = 500; +function getCompiled(exp) { + let compiled = expressionCache.get(exp); + if (!compiled) { + compiled = (0, expression_1.parseExpression)(exp); + if (expressionCache.size >= EXPRESSION_CACHE_MAX) { + expressionCache.clear(); + } + expressionCache.set(exp, compiled); + } + return compiled; +} +/** + * Picture x to device x. + * + * Returns NaN for input it cannot transform. Returning 0 instead — as this did + * — invents a real coordinate at the origin, so a command with one bad value + * drew a plausible shape in the wrong place rather than failing. Callers detect + * the non-finite result and skip the element; see `pspicture` in dsh-psgraph. + * + * @param v - the coordinate in picture units + * @returns the device coordinate, or NaN when it cannot be computed + */ const X = function (v) { - // Enhanced validation for coordinate transformation const numV = typeof v === 'string' ? parseFloat(v) : v; - if (isNaN(numV)) { - console.warn('X function: Invalid input value', { input: v, parsed: numV }); - return 0; - } - if (isNaN(this.w) || isNaN(this.x1) || isNaN(this.xunit)) { - console.warn('X function: NaN detected in context properties', { w: this.w, x1: this.x1, xunit: this.xunit }); - return 0; - } - // Validate context properties are reasonable - if (this.xunit <= 0) { - console.warn('X function: Invalid xunit value', { xunit: this.xunit }); - return 0; - } - // Use more precise calculation with proper parentheses + if (isNaN(numV)) + return NaN; + if (isNaN(this.w) || isNaN(this.x1) || isNaN(this.xunit)) + return NaN; + if (this.xunit <= 0) + return NaN; const result = (this.w - (this.x1 - numV)) * this.xunit; - // Validate result is finite - if (!isFinite(result)) { - console.warn('X function: Non-finite result', { - input: numV, - w: this.w, - x1: this.x1, - xunit: this.xunit, - result - }); - return 0; - } + if (!isFinite(result)) + return NaN; return Math.round(result * 100) / 100; // Round to 2 decimal places for pixel precision }; exports.X = X; @@ -2331,34 +6960,25 @@ const Xinv = function (v) { return Number(v) / this.xunit - this.w + this.x1; }; exports.Xinv = Xinv; +/** + * Picture y to device y, inverting the axis: SVG's y grows downward. + * + * Returns NaN for input it cannot transform, for the same reason as {@link X}. + * + * @param v - the coordinate in picture units + * @returns the device coordinate, or NaN when it cannot be computed + */ const Y = function (v) { - // Enhanced validation for coordinate transformation const numV = typeof v === 'string' ? parseFloat(v) : v; - if (isNaN(numV)) { - console.warn('Y function: Invalid input value', { input: v, parsed: numV }); - return 0; - } - if (isNaN(this.y1) || isNaN(this.yunit)) { - console.warn('Y function: NaN detected in context properties', { y1: this.y1, yunit: this.yunit }); - return 0; - } - // Validate context properties are reasonable - if (this.yunit <= 0) { - console.warn('Y function: Invalid yunit value', { yunit: this.yunit }); - return 0; - } - // Use more precise calculation for Y coordinate inversion + if (isNaN(numV)) + return NaN; + if (isNaN(this.y1) || isNaN(this.yunit)) + return NaN; + if (this.yunit <= 0) + return NaN; const result = (this.y1 - numV) * this.yunit; - // Validate result is finite - if (!isFinite(result)) { - console.warn('Y function: Non-finite result', { - input: numV, - y1: this.y1, - yunit: this.yunit, - result - }); - return 0; - } + if (!isFinite(result)) + return NaN; return Math.round(result * 100) / 100; // Round to 2 decimal places for pixel precision }; exports.Y = Y; @@ -2371,8 +6991,13 @@ exports.dotType = exports.parseArrows; var svg_utils_1 = require("./svg-utils"); Object.defineProperty(exports, "SVGSelection", { enumerable: true, get: function () { return svg_utils_1.SVGSelection; } }); Object.defineProperty(exports, "select", { enumerable: true, get: function () { return svg_utils_1.select; } }); +var expression_2 = require("./expression"); +Object.defineProperty(exports, "parseExpression", { enumerable: true, get: function () { return expression_2.parseExpression; } }); +Object.defineProperty(exports, "ExpressionError", { enumerable: true, get: function () { return expression_2.ExpressionError; } }); +Object.defineProperty(exports, "MATH_FUNCTIONS", { enumerable: true, get: function () { return expression_2.MATH_FUNCTIONS; } }); +Object.defineProperty(exports, "MATH_CONSTANTS", { enumerable: true, get: function () { return expression_2.MATH_CONSTANTS; } }); -},{"./svg-utils":21}],21:[function(require,module,exports){ +},{"./expression":24,"./svg-utils":26}],26:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SVGSelection = void 0; @@ -2438,11 +7063,16 @@ class SVGSelection { node() { return this.elements[0] || null; } + /** + * Sets an element's text content. + * + * `textContent` is defined on every Element, so no narrowing is needed — and + * testing `instanceof SVGTextElement` threw a ReferenceError outright in any + * DOM that does not expose that constructor as a global, jsdom included. + */ text(content) { this.elements.forEach(el => { - if (el instanceof SVGTextElement || el instanceof HTMLElement) { - el.textContent = content; - } + el.textContent = content; }); return this; } @@ -2456,5 +7086,5 @@ function select(selector) { return new SVGSelection(selector); } -},{}]},{},[7])(7) +},{}]},{},[8])(8) }); diff --git a/examples/index.html b/examples/index.html index 45b080d..be652a2 100644 --- a/examples/index.html +++ b/examples/index.html @@ -131,24 +131,26 @@

    LaTeX2JS Examples




    +\definecolor{lightblue}{RGB}{173,216,230} \begin{pspicture}(-2,-2)(2,2) \psframe(-2,-2)(2,2) \userline[linewidth=2pt,linecolor=green]{->}(0,0)(2,2){-x}{-y} \userline[linewidth=2pt,linecolor=red]{->}(0,0)(2,2){0}{y} -\userline[linewidth=2pt,linecolor=purple]{->}(0,0)(2,2){-x}{cos(y)} -\userline[linewidth=2pt,linecolor=lightblue]{->}(0,0)(2,2)(sin(x)}{-y} +\userline[linewidth=2pt,linecolor=violet]{->}(0,0)(2,2){-x}{cos(y)} +\userline[linewidth=2pt,linecolor=lightblue]{->}(0,0)(2,2){sin(x)}{-y} \end{pspicture}
    source:
    \begin{verbatim} +\definecolor{lightblue}{RGB}{173,216,230} \begin{pspicture}(-2,-2)(2,2) \psframe(-2,-2)(2,2) \userline[linewidth=2pt,linecolor=green]{->}(0,0)(2,2){-x}{-y} \userline[linewidth=2pt,linecolor=red]{->}(0,0)(2,2){0}{y} -\userline[linewidth=2pt,linecolor=purple]{->}(0,0)(2,2){-x}{cos(y)} -\userline[linewidth=2pt,linecolor=lightblue]{->}(0,0)(2,2)(sin(x)}{-y} +\userline[linewidth=2pt,linecolor=violet]{->}(0,0)(2,2){-x}{cos(y)} +\userline[linewidth=2pt,linecolor=lightblue]{->}(0,0)(2,2){sin(x)}{-y} \end{pspicture} \end{verbatim} diff --git a/index.html b/index.html index b4b2df9..201c87f 100644 --- a/index.html +++ b/index.html @@ -175,23 +175,26 @@

    Help spread the word!

    If you specify more arguments, you can create functions for the head and and tail of the vector, which each takes the current $x$ and $y$ position of the users finger or cursor as they move and produces the following interaction: +\definecolor{lightblue}{RGB}{173,216,230} + \begin{center} \begin{pspicture}(-2,-2)(2,2) \psframe(-2,-2)(2,2) \userline[linewidth=2pt,linecolor=green]{->}(0,0)(2,2){-x}{-y} \userline[linewidth=2pt,linecolor=red]{->}(0,0)(2,2){0}{y} -\userline[linewidth=2pt,linecolor=purple]{->}(0,0)(2,2){-x}{cos(y)} -\userline[linewidth=2pt,linecolor=lightblue]{->}(0,0)(2,2)(sin(x)}{-y} +\userline[linewidth=2pt,linecolor=violet]{->}(0,0)(2,2){-x}{cos(y)} +\userline[linewidth=2pt,linecolor=lightblue]{->}(0,0)(2,2){sin(x)}{-y} \end{pspicture} \end{center} 2 extra arguments provide functions for the head, 4 extra arguments allows you to control both and tail \begin{verbatim} +\definecolor{lightblue}{RGB}{173,216,230} \userline[linewidth=2pt,linecolor=green]{->}(0,0)(2,2){-x}{-y} \userline[linewidth=2pt,linecolor=red]{->}(0,0)(2,2){0}{y} -\userline[linewidth=2pt,linecolor=purple]{->}(0,0)(2,2){-x}{cos(y)} -\userline[linewidth=2pt,linecolor=lightblue]{->}(0,0)(2,2)(sin(x)}{-y} +\userline[linewidth=2pt,linecolor=violet]{->}(0,0)(2,2){-x}{cos(y)} +\userline[linewidth=2pt,linecolor=lightblue]{->}(0,0)(2,2){sin(x)}{-y} \end{verbatim} I can also draw a more complex version, and start to make more useful diagrams to describe vectors: