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