diff --git a/bundle/latex2html5.bundle.js b/bundle/latex2html5.bundle.js index 7abdb077..64754709 100644 --- a/bundle/latex2html5.bundle.js +++ b/bundle/latex2html5.bundle.js @@ -86,11 +86,16 @@ function render(_that) { "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; } },{}],5:[function(require,module,exports){ @@ -2173,6 +2178,14 @@ const counters_1 = require("./counters"); * 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`. * @@ -2389,8 +2402,10 @@ class Parser { parse(text) { this.diagnostics = []; // A parser instance is reused across documents; without this the second - // would continue the first one's numbering. + // 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 []; const tree = this.parseTree(text); @@ -2638,6 +2653,10 @@ class Parser { 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); @@ -2655,6 +2674,9 @@ class Parser { 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); } @@ -2667,39 +2689,62 @@ class Parser { return this.isIgnored('\\begin{' + name + '}'); } /** - * A blank source line becomes a `
`, but a heading already carries its own - * margins, so a `
` next to one stacks two gaps where the author asked for - * one. Dropping the adjacent break leaves the heading's own spacing to do the - * work — and a run of breaks collapses to a single paragraph gap. + * 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 */ - collapseBreaks(lines) { - const isBlock = (l) => /^\s*<(h[1-6]|ul|ol|li|p|div|table|blockquote)\b/i.test(l); + 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) { - if (line !== '
') { - while (isBlock(line) && out[out.length - 1] === '
') - out.pop(); - out.push(line); + // 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 (!out.length) - continue; - if (isBlock(out[out.length - 1])) - continue; - if (out[out.length - 1] === '
') + if (isBlock(line)) { + flush(); + out.push(line); continue; - out.push(line); + } + para.push(line); } - while (out[out.length - 1] === '
') - out.pop(); + flush(); return out; } newEnvironment(type) { if (this.environment && (this.environment.lines.length || this.environment.type !== 'math')) { this.environment.settings = { ...this.settings }; - if (!this.environment.type.match(/pspicture|verbatim/)) { - this.environment.lines = this.collapseBreaks(this.environment.lines); + // 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); } @@ -2708,6 +2753,23 @@ class Parser { 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.replace(/\n/g, ' ').match(this.PSTricks.Expressions.psset); const declared = this.PSTricks.Functions.psset.call(this, m); @@ -6557,7 +6619,7 @@ function parseExpression(source) { },{}],25:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -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.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) { @@ -6635,6 +6697,75 @@ const BASE_COLORS = { 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. * @@ -6648,7 +6779,9 @@ const BASE_COLORS = { */ const resolveColor = function (value) { const parts = String(value).split('!').map((p) => p.trim()); - const rgb = (name) => BASE_COLORS[name.toLowerCase()] ?? null; + // 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. diff --git a/packages/css/latex2js.css b/packages/css/latex2js.css index f70eda93..90b9a8b6 100644 --- a/packages/css/latex2js.css +++ b/packages/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 { @@ -130,4 +140,20 @@ h4.theorem-head::after { pre { overflow: auto; -} \ No newline at end of file +} +/* + * 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/packages/html5/src/components/math.ts b/packages/html5/src/components/math.ts index 1d66358c..9a12c84b 100644 --- a/packages/html5/src/components/math.ts +++ b/packages/html5/src/components/math.ts @@ -3,9 +3,14 @@ interface ComponentProps { [key: string]: any; } -export default function render(that: ComponentProps): HTMLSpanElement { - const span = document.createElement('span'); - span.className = 'math'; - span.innerHTML = that.lines.join('\n'); - return span; +/** + * 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. + */ +export default function render(that: ComponentProps): HTMLDivElement { + const div = document.createElement('div'); + div.className = 'math'; + div.innerHTML = that.lines.join('\n'); + return div; } diff --git a/packages/latex2js/src/lib/parser.ts b/packages/latex2js/src/lib/parser.ts index 08aaa975..20859f87 100644 --- a/packages/latex2js/src/lib/parser.ts +++ b/packages/latex2js/src/lib/parser.ts @@ -1,7 +1,7 @@ import * as pegParser from '../grammar/parser.js'; import { dialectUses } from './dialect'; import { normalizeDialect } from '@latex2js/settings'; -import { normalizeArrows } from '@latex2js/utils'; +import { normalizeArrows, defineColor, resetDefinedColors } from '@latex2js/utils'; import { Counters, SectionLevel } from './counters'; export interface Diagnostic { @@ -61,6 +61,15 @@ type Segment = */ 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`. * @@ -287,8 +296,10 @@ class Parser { parse(text: string): any[] { this.diagnostics = []; // A parser instance is reused across documents; without this the second - // would continue the first one's numbering. + // would continue the first one's numbering, and inherit any colour the + // first defined for itself. this.counters.reset(); + resetDefinedColors(); if (!text) return []; const tree = this.parseTree(text); this.walk(tree); @@ -542,6 +553,10 @@ class Parser { 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); } @@ -558,6 +573,8 @@ class Parser { 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); } @@ -573,26 +590,52 @@ class Parser { } /** - * A blank source line becomes a `
    `, but a heading already carries its own - * margins, so a `
    ` next to one stacks two gaps where the author asked for - * one. Dropping the adjacent break leaves the heading's own spacing to do the - * work — and a run of breaks collapses to a single paragraph gap. + * 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 */ - collapseBreaks(lines: string[]): string[] { - const isBlock = (l: string) => /^\s*<(h[1-6]|ul|ol|li|p|div|table|blockquote)\b/i.test(l); + paragraphize(lines: string[]): string[] { + const isBlock = (l: string) => + /^\s*<(h[1-6]|ul|ol|li|p|div|table|blockquote|pre|figure)\b/i.test(l); const out: string[] = []; + let para: string[] = []; + + const flush = (): void => { + if (!para.length) return; + out.push('

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

    '); + para = []; + }; + for (const line of lines) { - if (line !== '
    ') { - while (isBlock(line) && out[out.length - 1] === '
    ') out.pop(); + // 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; } - if (!out.length) continue; - if (isBlock(out[out.length - 1])) continue; - if (out[out.length - 1] === '
    ') continue; - out.push(line); + para.push(line); } - while (out[out.length - 1] === '
    ') out.pop(); + flush(); return out; } @@ -602,8 +645,12 @@ class Parser { (this.environment.lines.length || this.environment.type !== 'math') ) { this.environment.settings = { ...this.settings }; - if (!this.environment.type.match(/pspicture|verbatim/)) { - this.environment.lines = this.collapseBreaks(this.environment.lines); + // 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); } @@ -613,6 +660,27 @@ class Parser { }; } + /** + * 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: string, loc?: any): void { + const m = String(text || '').match(DEFINECOLOR_RE); + if (!m) return; + if (!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: string): void { var m = line.replace(/\n/g, ' ').match(this.PSTricks.Expressions.psset); const declared = this.PSTricks.Functions.psset.call(this, m); diff --git a/packages/latex2js/test/__snapshots__/parser.test.ts.snap b/packages/latex2js/test/__snapshots__/parser.test.ts.snap index d1261d06..8d48c577 100644 --- a/packages/latex2js/test/__snapshots__/parser.test.ts.snap +++ b/packages/latex2js/test/__snapshots__/parser.test.ts.snap @@ -4,7 +4,7 @@ exports[`Parser parse 1`] = ` [ { "lines": [ - "Let's get to the point. The core of PSTricks is graphics!", + "

    Let's get to the point. The core of PSTricks is graphics!

    ", ], "settings": { "fillstyle": "none", @@ -732,7 +732,7 @@ exports[`Parser parse 1`] = ` }, { "lines": [ - "which can be produced using the following $\\TeX$:", + "

    which can be produced using the following $\\TeX$:

    ", ], "settings": { "fillstyle": "none", @@ -805,7 +805,7 @@ exports[`Parser parser 1`] = ` [ { "lines": [ - "Let's get to the point. The core of PSTricks is graphics!", + "

    Let's get to the point. The core of PSTricks is graphics!

    ", ], "settings": { "fillstyle": "none", @@ -1533,7 +1533,7 @@ exports[`Parser parser 1`] = ` }, { "lines": [ - "which can be produced using the following $\\TeX$:", + "

    which can be produced using the following $\\TeX$:

    ", ], "settings": { "fillstyle": "none", diff --git a/packages/latex2js/test/definecolor.test.ts b/packages/latex2js/test/definecolor.test.ts new file mode 100644 index 00000000..a3e5ad9a --- /dev/null +++ b/packages/latex2js/test/definecolor.test.ts @@ -0,0 +1,104 @@ +import LaTeX2JS from '../src'; + +/** + * `\definecolor` is how a document names a colour xcolor does not define. + * + * It matters for more than convenience: a page written against browser colour + * names — `lightblue`, say — is not valid LaTeX at all, because xcolor rejects + * the name outright (`! Package xcolor Error: Undefined color 'lightblue'`). + * Defining the colour is what lets such a page keep the exact shade it wants + * and still compile, instead of the renderer quietly accepting a name the + * specification does not have. + */ +function shape(tex: string, name = 'pscircle'): any { + const l = new LaTeX2JS(); + const parsed: any = l.parse(tex); + const env = parsed.find((e: any) => e.type === 'pspicture'); + expect(env).toBeDefined(); + return (env.plot[name] || [])[0]?.data; +} + +const picture = (body: string) => + `\\begin{pspicture}(-3,-2.5)(3,2.5)\n${body}\n\\end{pspicture}`; + +describe('definecolor names a colour the document can use', () => { + it('reads the RGB model, 0 to 255', () => { + const c = shape( + '\\definecolor{lightblue}{RGB}{173,216,230}\n' + picture('\\pscircle[linecolor=lightblue](0,0){1}') + ); + expect(c.linecolor).toBe('rgb(173,216,230)'); + }); + + it('reads the rgb model, fractions', () => { + const c = shape( + '\\definecolor{half}{rgb}{0.5,0,1}\n' + picture('\\pscircle[linecolor=half](0,0){1}') + ); + expect(c.linecolor).toBe('rgb(128,0,255)'); + }); + + it('reads the gray model', () => { + const c = shape( + '\\definecolor{mid}{gray}{0.5}\n' + picture('\\pscircle[linecolor=mid](0,0){1}') + ); + expect(c.linecolor).toBe('rgb(128,128,128)'); + }); + + it('reads the HTML model', () => { + const c = shape( + '\\definecolor{brand}{HTML}{ADD8E6}\n' + picture('\\pscircle[linecolor=brand](0,0){1}') + ); + expect(c.linecolor).toBe('rgb(173,216,230)'); + }); + + it('reads the cmyk model', () => { + const c = shape( + '\\definecolor{ink}{cmyk}{0,1,1,0}\n' + picture('\\pscircle[linecolor=ink](0,0){1}') + ); + expect(c.linecolor).toBe('rgb(255,0,0)'); + }); + + it('lets a definition shadow an xcolor built-in, as xcolor does', () => { + const c = shape( + '\\definecolor{purple}{RGB}{128,0,128}\n' + picture('\\pscircle[linecolor=purple](0,0){1}') + ); + expect(c.linecolor).toBe('rgb(128,0,128)'); + }); + + it('is usable as the base of a tint expression', () => { + const c = shape( + '\\definecolor{brand}{RGB}{200,0,0}\n' + picture('\\pscircle[linecolor=brand!50](0,0){1}') + ); + // Fifty percent against white. + expect(c.linecolor).toBe('rgb(228,128,128)'); + }); + + it('does not render the declaration as text', () => { + const l = new LaTeX2JS(); + const parsed: any = l.parse('\\definecolor{brand}{RGB}{1,2,3}\nSome prose.\n'); + const text = parsed.map((o: any) => (o.lines || []).join('\n')).join('\n'); + expect(text).not.toContain('definecolor'); + expect(text).toContain('Some prose.'); + }); +}); + +describe('definitions belong to a document, not to the parser', () => { + it('does not leak into the next parse of the same instance', () => { + const l = new LaTeX2JS(); + l.parse('\\definecolor{brand}{RGB}{1,2,3}\n' + picture('\\pscircle[linecolor=brand](0,0){1}')); + const second: any = l.parse(picture('\\pscircle[linecolor=brand](0,0){1}')); + const env = second.find((e: any) => e.type === 'pspicture'); + // Undefined now, so the name passes through untouched rather than keeping + // the previous document's value. + expect(env.plot.pscircle[0].data.linecolor).toBe('brand'); + }); +}); + +describe('a definition it cannot read is reported, not guessed at', () => { + it('warns about an unknown colour model', () => { + const l: any = new LaTeX2JS(); + l.parse('\\definecolor{odd}{spectral}{1,2,3}\n' + picture('\\pscircle(0,0){1}')); + const warnings = (l.lastDiagnostics || []).filter((d: any) => /definecolor/.test(d.message)); + expect(warnings.length).toBeGreaterThan(0); + expect(warnings[0].message).toContain('spectral'); + }); +}); diff --git a/packages/latex2js/test/list-lines.test.ts b/packages/latex2js/test/list-lines.test.ts index bded600a..39cc61a6 100644 --- a/packages/latex2js/test/list-lines.test.ts +++ b/packages/latex2js/test/list-lines.test.ts @@ -36,7 +36,11 @@ describe('list items stay on one line', () => { }); it('matches how the same text renders outside a list', () => { - expect(lines('Some \\textbf{bold} text here\n')).toEqual(['Some bold text here']); + // Outside a list the text is a paragraph, so the transform is compared + // through that wrapper rather than against a bare line. + expect(lines('Some \\textbf{bold} text here\n')).toEqual([ + '

    Some bold text here

    ', + ]); }); it('keeps a blank line between items as a paragraph break', () => { diff --git a/packages/latex2js/test/parser-semantics.test.ts b/packages/latex2js/test/parser-semantics.test.ts index 38ef4142..90bdd502 100644 --- a/packages/latex2js/test/parser-semantics.test.ts +++ b/packages/latex2js/test/parser-semantics.test.ts @@ -453,3 +453,66 @@ a &= b expect(text).toContain('\\end{align}'); }); }); + +/** + * 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 document style, not something an author dials in by pressing + * return more times. + * + * 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. + */ +describe('blank lines separate paragraphs, as they do in TeX', () => { + const lines = (tex: string): string[] => { + const l = new LaTeX2JS(); + const parsed: any = l.parse(tex); + return parsed.flatMap((o: any) => o.lines || []); + }; + const paras = (out: string[]) => out.filter((x) => /^

    /.test(x)); + + it('makes two paragraphs out of text either side of a blank line', () => { + const out = lines('First.\n\nSecond.\n'); + expect(paras(out)).toHaveLength(2); + expect(out.join('')).toContain('First.'); + expect(out.join('')).toContain('Second.'); + }); + + it('treats any number of blank lines as one break', () => { + // The property that makes this TeX rather than a text editor: pressing + // return more times does not make a bigger gap. + const one = lines('First.\n\nSecond.\n'); + const two = lines('First.\n\n\nSecond.\n'); + const many = lines('First.\n\n\n\n\n\nSecond.\n'); + expect(two).toEqual(one); + expect(many).toEqual(one); + }); + + it('emits no break elements for blank lines at all', () => { + // The gap is the paragraph's margin now, which a stylesheet can reach. + expect(lines('First.\n\n\nSecond.\n').filter((x) => x === '
    ')).toHaveLength(0); + }); + + it('keeps consecutive lines inside one paragraph', () => { + const out = lines('One line.\nStill the same paragraph.\n\nA new one.\n'); + const p = paras(out); + expect(p).toHaveLength(2); + expect(p[0]).toContain('One line.'); + expect(p[0]).toContain('Still the same paragraph.'); + }); + + it('does not wrap a heading in a paragraph', () => { + // A heading is a block with its own margins, not part of a paragraph. + const out = lines('Text.\n\n\\section{Heading}\n\nMore text.\n'); + const heading = out.find((x) => //); + expect(paras(out)).toHaveLength(2); + }); + + it('leaves no empty paragraph for a document that ends in blank lines', () => { + const out = lines('Only line.\n\n\n'); + expect(paras(out)).toHaveLength(1); + expect(out.join('')).not.toContain('

    '); + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index a85d9d5e..83d6d7cc 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -80,6 +80,77 @@ const BASE_COLORS: { [name: string]: [number, number, number] } = { 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: { [name: string]: [number, number, number] } = {}; + +/** Clears the document-defined colours. Called once per parse. */ +export const resetDefinedColors = function (): void { + for (const name of Object.keys(DEFINED_COLORS)) delete DEFINED_COLORS[name]; +}; + +const clamp255 = (n: number) => 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 + */ +export const defineColor = function (name: string, model: string, spec: string): boolean { + 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; +}; + /** * Resolves an xcolor tint expression to a CSS colour. * @@ -94,8 +165,10 @@ const BASE_COLORS: { [name: string]: [number, number, number] } = { export const resolveColor = function (value: string): string { 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: string): [number, number, number] | null => - BASE_COLORS[name.toLowerCase()] ?? null; + 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