From 7c721e51efe4044f1c1c4d6edc361591936a7e97 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 16 Aug 2026 16:39:25 -0700 Subject: [PATCH 1/2] feat: honour \definecolor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xcolor lets a document name its own colours. Without that, a page wanting a shade xcolor does not define had no valid way to ask for it — and the pages on latex2js.com ask for `lightblue`, which real LaTeX rejects outright: ! Package xcolor Error: Undefined color `lightblue'. So those pages could not compile at all, and only rendered here because this handed unknown names to the browser, where CSS happens to know that one. That is the gap this closes: `\definecolor{lightblue}{RGB}{173,216,230}` says exactly which colour is meant, in a line LaTeX and this agree on. All five xcolor models are read — rgb and cmyk as fractions, RGB as 0-255, gray as one fraction, HTML as six hex digits — and a definition may shadow a built-in of the same name, as it may in xcolor. A model this cannot read is reported against the declaration rather than silently ignored. Definitions belong to a document, not to the parser: they are cleared at the start of each parse, next to the counters, so a reused instance does not inherit the previous document's palette. Intercepted where \psset is rather than in the command walk. The grammar delivers a command inside the line that holds it, not as a node of its own, so a walk-level handler never fires and the declaration would be rendered as text. --- bundle/latex2html5.bundle.js | 111 ++++++++++++++++++++- packages/latex2js/src/lib/parser.ts | 42 +++++++- packages/latex2js/test/definecolor.test.ts | 104 +++++++++++++++++++ packages/utils/src/index.ts | 75 +++++++++++++- 4 files changed, 326 insertions(+), 6 deletions(-) create mode 100644 packages/latex2js/test/definecolor.test.ts diff --git a/bundle/latex2html5.bundle.js b/bundle/latex2html5.bundle.js index 7abdb07..7fc6852 100644 --- a/bundle/latex2html5.bundle.js +++ b/bundle/latex2html5.bundle.js @@ -2173,6 +2173,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 +2397,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 +2648,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 +2669,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); } @@ -2708,6 +2725,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 +6591,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 +6669,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 +6751,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/latex2js/src/lib/parser.ts b/packages/latex2js/src/lib/parser.ts index 08aaa97..af89102 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); } @@ -613,6 +630,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/definecolor.test.ts b/packages/latex2js/test/definecolor.test.ts new file mode 100644 index 0000000..a3e5ad9 --- /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/utils/src/index.ts b/packages/utils/src/index.ts index a85d9d5..83d6d7c 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 From 8b3ee0c5a1e902c52e061d82347d3488a3cca762 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 16 Aug 2026 18:47:05 -0700 Subject: [PATCH 2/2] feat: paragraphs, the way TeX means them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A blank line became a `
`, one per blank line, so the gap between two paragraphs was however many times the author happened to press return, and no stylesheet could reach it. That is not what a blank line means in TeX. In TeX a run of blank lines — of any length — is a single \par, and the space between paragraphs comes from \parskip: a document style, set once. Two blank lines and one are the same input. So text is grouped into real paragraphs now. A run of breaks ends one, however long the run is, and the gap is the paragraph's own margin, named in the stylesheet as --latex2js-parskip. That is the whole point of the change: the spacing became a style, which is both what TeX means and the only version a theme can restyle. A picture takes the same value, so a figure and a paragraph sit apart by the same amount. Only the plain text environment is grouped. A list keeps its \item lines for its own component to turn into
  • , verbatim is literal, a picture is commands, and a nicebox is one inline run — wrapping any of those in paragraphs breaks the element that consumes them. The text container is a div rather than a span, because a

    inside a is invalid nesting that a browser silently hoists out, taking the text with it. This supersedes the blank-line collapsing added a few commits ago. That change was written for a real problem — a break beside a heading stacks against the heading's own margin — but it also folded runs of breaks between ordinary paragraphs into one, which halved the gap on every page. The problem was adjacency, not repetition, and modelling paragraphs properly removes both. --- bundle/latex2html5.bundle.js | 76 +++++++++++++------ packages/css/latex2js.css | 30 +++++++- packages/html5/src/components/math.ts | 15 ++-- packages/latex2js/src/lib/parser.ts | 60 +++++++++++---- .../test/__snapshots__/parser.test.ts.snap | 8 +- packages/latex2js/test/list-lines.test.ts | 6 +- .../latex2js/test/parser-semantics.test.ts | 63 +++++++++++++++ 7 files changed, 207 insertions(+), 51 deletions(-) diff --git a/bundle/latex2html5.bundle.js b/bundle/latex2html5.bundle.js index 7fc6852..6475470 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){ @@ -2684,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); } diff --git a/packages/css/latex2js.css b/packages/css/latex2js.css index f70eda9..90b9a8b 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 1d66358..9a12c84 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 af89102..20859f8 100644 --- a/packages/latex2js/src/lib/parser.ts +++ b/packages/latex2js/src/lib/parser.ts @@ -590,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; } @@ -619,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); } diff --git a/packages/latex2js/test/__snapshots__/parser.test.ts.snap b/packages/latex2js/test/__snapshots__/parser.test.ts.snap index d1261d0..8d48c57 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/list-lines.test.ts b/packages/latex2js/test/list-lines.test.ts index bded600..39cc61a 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 38ef414..90bdd50 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('

    '); + }); +});