Summary
In a $...$ / $$...$$ math fragment, the LaTeX macro \cdots renders as \[CenterDot] (·) immediately followed by a literal italic s (i.e. "· s") instead of a centered ellipsis \[CenterEllipsis] (⋯) whenever \cdots sits in a juxtaposition run next to other tokens. \cdots alone renders correctly, which is what made this easy to miss.
It surfaced in a real essay where \mathrm{Tr}[\,\cdots\,] came out as Tr[ · s ]; that document currently works around it with \ldots.
Same class as #11 (\cdot -> ×), #13 (\, leak), #25 (spacing/token leaks), #61 (\hbar blank box): a TeX token the LaTeX path mishandles, fixed by a pre-substitution in wolframParserTeX plus an inverse map.
Minimal repro
Get["MarkdownToNotebook.wl"]; Get["NotebookToMarkdown.wl"];
texBoxes["\\cdots"]
(* "⋯" -- correct in isolation *)
texBoxes["a\\cdots b"]
(* RowBox[{StyleBox["a","TI"], "·", StyleBox["s","TI"], StyleBox["b","TI"]}]
-- BUG: \[CenterDot] then a stray italic "s" *)
texBoxes["\\mathrm{Tr}[\\,\\cdots\\,]"]
(* ... RowBox[{" ", "·", StyleBox["s","TI"], " "}] ... -- the essay case *)
Full-document round-trip:
nb = MarkdownToNotebook["Trace $\\mathrm{Tr}[\\,\\cdots\\,]$.", "Evaluate" -> False];
NotebookToMarkdown[nb]
(* the math returns with "\cdot s", never "\cdots" *)
Root cause
The pre-substitution block in wolframParserTeX (MarkdownToNotebook.wl) maps \cdot -> \[CenterDot] behind a negative-lookahead guard \cdot(?![a-zA-Z]) (added for #11, else \cdot maps to ×). That guard deliberately rejects \cdots (the s is a letter), leaving \cdots for the primary parser WolframParserLaTeXMathParse. The existing comment even says so: "The cdot guard rejects \cdots (centered ellipsis)."
The unstated assumption was that the parser then handles \cdots. It does, but only in some positions. Probing both paths (WolframParser submodule at v1.1.2, 7885e60):
| input |
LaTeXMathParse (primary) |
ImportString[..., "TeX"] (fallback) |
\cdots (alone) |
⋯ correct |
⋯ correct |
a\cdots b |
a · s b BUG |
a ⋯ b correct |
\mathrm{Tr}[\,\cdots\,] |
Tr[ · s ] BUG |
(not reached; primary succeeds) |
So the fault is in the primary parser (the vendored WolframParser LaTeXMathParse), which mis-lexes \cdots as \cdot + s in a juxtaposition run. wolframParserTeX returns that buggy result successfully (not $Failed), so the ImportString fallback, which actually handles \cdots correctly, is never reached.
Two consequences shape the fix:
- The fix belongs in
wolframParserTeX (feed the parser the glyph, not \cdots), not in texImportStrip. Adding a raw-⋯ substitution to the fallback would break it: ImportString[..., "TeX"] reads its input as Latin-1, so a raw U+22EF comes back as mojibake (â¯).
WolframParserExportLaTeX (which NotebookToMarkdown's walkerMath delegates to since 33e95dc) has no TeX name for \[CenterEllipsis] and serializes it as the raw glyph (same for \[VerticalEllipsis] / \[DescendingEllipsis]), so a reverse map is needed for a faithful round-trip.
As with #11, the true fault is in the vendored parser, but the pragmatic, self-contained fix is the md2nb-side pre-substitution.
Fix
Mirrors #11 / #61:
- Forward (
wolframParserTeX): pre-substitute \cdots -> \[CenterEllipsis] before the \cdot rule (order matters, so \cdots is not caught by the shorter \cdot rule), with the same (?![a-zA-Z]) letter-boundary guard. The primary parser passes ⋯ through unchanged in every context.
- Reverse (
walkerMath): post-map ExportLaTeX's output, \[CenterEllipsis] -> \cdots (plus \[VerticalEllipsis] -> \vdots , \[DescendingEllipsis] -> \ddots for the same reverse gap), using the trailing-space terminator ExportLaTeX itself emits for \ldots / \cdot.
Scope is \cdots. \vdots / \ddots already render correctly forward (they contain no \cdot), but shared the reverse gap; the inverse map now round-trips them to their commands too.
Verification
texBoxes["a\\cdots b"] -> a ⋯ b, no \[CenterDot]; round-trips to $a\cdots b$, and is idempotent (twin === twin2 on the pure-\cdots family).
- The built
.nb for the repro holds \[CenterEllipsis] boxes and zero CenterDot-adjacent-to-a-letter.
- Regressions hold:
\ldots / \dots -> …, \cdot alone -> ·, and a\cdot b + c\cdots d keeps both correct in one expression.
- Repo suite:
tests.wls 333/333 (one regression test added), check.wls clean.
- The essay rebuilds clean (its
\ldots workaround and all other math intact).
Separate, unrelated observation found while testing: \mathrm{Tr} serializes back through ExportLaTeX as \Tr, which is not valid LaTeX and does not re-parse on a second build (the bracketed content is dropped and the span becomes an inline code span). That is an independent \mathrm{Tr} <-> \Tr round-trip issue, orthogonal to \cdots.
Diff (uncommitted working-tree change)
--- a/MarkdownToNotebook.wl
+++ b/MarkdownToNotebook.wl
@@ -2992,10 +2992,14 @@
If[ Names["Wolfram`Parser`LaTeXMathParse"] === {},
$Failed,
Module[{m, r},
- (* pre-substitute TeX tokens the parser mishandles: \cdot -> · (else
- it maps to ×, issue #11), \, -> control-space (else literal "\,"
- leaks, issue #13). The cdot guard rejects \cdots (centered ellipsis). *)
+ (* pre-substitute TeX tokens the parser mishandles: \cdots -> ⋯ (in a
+ juxtaposition run the parser lexes \cdots as \cdot + a stray "s", so
+ catch it BEFORE the \cdot rule; sibling of #11), \cdot -> · (else it
+ maps to ×, issue #11), \, -> control-space (else literal "\," leaks,
+ issue #13). Each rule guards a trailing letter so a longer command is
+ left whole. *)
m = StringReplace[math, {
+ RegularExpression["\\\\cdots(?![a-zA-Z])"] -> "\[CenterEllipsis]",
RegularExpression["\\\\cdot(?![a-zA-Z])"] -> "\[CenterDot]",
"\\," -> "\\ "
}];
--- a/NotebookToMarkdown.wl
+++ b/NotebookToMarkdown.wl
@@ -64,9 +64,19 @@
GridBox[{{inner_GridBox}}, o___] /; absGridQ[GridBox[{{inner}}, o]] && absGridQ[inner] :>
TemplateBox[{inner[[1, 1, 1]]}, "Norm"],
g : GridBox[{{inner_}}, ___] /; absGridQ[g] :> TemplateBox[{inner}, "Abs"]
+}
+(* ExportLaTeX has no TeX name for the centered / vertical / descending ellipsis
+ glyphs, so it serializes them as the raw Unicode character; map each back to its
+ LaTeX command (trailing space is the terminator ExportLaTeX itself emits for
+ \ldots / \cdot) so the round-trip returns \cdots / \vdots / \ddots. \cdots is the
+ reverse partner of the forward \cdots pre-substitution (sibling of #11). *)
+$ellipsisTeX = {
+ "\[CenterEllipsis]" -> "\\cdots ",
+ "\[VerticalEllipsis]" -> "\\vdots ",
+ "\[DescendingEllipsis]" -> "\\ddots "
}
(* box tree -> LaTeX math body (inverse of LaTeXMathParse); callers keep the name *)
-walkerMath[x_] := Wolfram`Parser`ExportLaTeX[absGridToTemplate[x]]
+walkerMath[x_] := StringReplace[Wolfram`Parser`ExportLaTeX[absGridToTemplate[x]], $ellipsisTeX]
(* === code-mode serializer ===
Box-form WL code -> source string. A code cell's BoxData carries the user's
--- a/MarkdownToNotebook.md
+++ b/MarkdownToNotebook.md
@@ -743,6 +743,19 @@
]
```
+The centered ellipsis `\cdots` is left for the primary parser by the `\cdot` guard, but in a juxtaposition run (`a\cdots b`, or `\mathrm{Tr}[\,\cdots\,]`) the parser lexes it as `\cdot` plus a stray `s`, printing a centered dot and a loose `s` instead of `\[CenterEllipsis]`. Pre-substituting `\cdots -> \[CenterEllipsis]` before the `\cdot` rule feeds the parser the glyph it renders correctly, and the inverse maps `\[CenterEllipsis]` back to `\cdots` (sibling of the `\cdot` fix, issue #11):
+
+```wl
+VerificationTest[
+ {FreeQ[texBoxes["a\\cdots b"], "\[CenterDot]"],
+ StringContainsQ[
+ NotebookToMarkdown @ MarkdownToNotebook["Row $a\\cdots b$ and $x_1\\cdots x_n$ here.", "Evaluate" -> False],
+ "\\cdots"]},
+ {True, True},
+ TestID -> "\\cdots is a centered ellipsis, not \\cdot + a stray s, and round-trips (sibling of issue #11)"
+]
+```
+
A single-backtick inline code span that reads as a filesystem path, URL, dotted filename (`` `~/.prime/config.json` ``), or hyphen-joined identifier (`` `claude-opus-4-7` ``) is kept verbatim instead of reparsed as Wolfram code - otherwise the front end tokenizes its `/` `.` `~` `-` as operators (ReplaceAll, Dot, Subtract, ...) and it renders with stray operator spacing (`config . json`, `claude - opus - 4 - 7`). Genuine WL inline code (`` `Range[5]` ``, `` `x_1` ``) still reparses to boxes:
```wl
Cross-refs: #11, #13, #25, #61.
Summary
In a
$...$/$$...$$math fragment, the LaTeX macro\cdotsrenders as\[CenterDot](·) immediately followed by a literal italics(i.e. "· s") instead of a centered ellipsis\[CenterEllipsis](⋯) whenever\cdotssits in a juxtaposition run next to other tokens.\cdotsalone renders correctly, which is what made this easy to miss.It surfaced in a real essay where
\mathrm{Tr}[\,\cdots\,]came out asTr[ · s ]; that document currently works around it with\ldots.Same class as #11 (
\cdot-> ×), #13 (\,leak), #25 (spacing/token leaks), #61 (\hbarblank box): a TeX token the LaTeX path mishandles, fixed by a pre-substitution inwolframParserTeXplus an inverse map.Minimal repro
Full-document round-trip:
Root cause
The pre-substitution block in
wolframParserTeX(MarkdownToNotebook.wl) maps\cdot -> \[CenterDot]behind a negative-lookahead guard\cdot(?![a-zA-Z])(added for #11, else\cdotmaps to ×). That guard deliberately rejects\cdots(thesis a letter), leaving\cdotsfor the primary parserWolframParserLaTeXMathParse. The existing comment even says so: "The cdot guard rejects \cdots (centered ellipsis)."The unstated assumption was that the parser then handles
\cdots. It does, but only in some positions. Probing both paths (WolframParser submodule at v1.1.2,7885e60):LaTeXMathParse(primary)ImportString[..., "TeX"](fallback)\cdots(alone)⋯correct⋯correcta\cdots ba · s bBUGa ⋯ bcorrect\mathrm{Tr}[\,\cdots\,]Tr[ · s ]BUGSo the fault is in the primary parser (the vendored WolframParser
LaTeXMathParse), which mis-lexes\cdotsas\cdot+sin a juxtaposition run.wolframParserTeXreturns that buggy result successfully (not$Failed), so theImportStringfallback, which actually handles\cdotscorrectly, is never reached.Two consequences shape the fix:
wolframParserTeX(feed the parser the glyph, not\cdots), not intexImportStrip. Adding a raw-⋯substitution to the fallback would break it:ImportString[..., "TeX"]reads its input as Latin-1, so a raw U+22EF comes back as mojibake (â¯).WolframParserExportLaTeX(whichNotebookToMarkdown'swalkerMathdelegates to since 33e95dc) has no TeX name for\[CenterEllipsis]and serializes it as the raw glyph (same for\[VerticalEllipsis]/\[DescendingEllipsis]), so a reverse map is needed for a faithful round-trip.As with #11, the true fault is in the vendored parser, but the pragmatic, self-contained fix is the md2nb-side pre-substitution.
Fix
Mirrors #11 / #61:
wolframParserTeX): pre-substitute\cdots -> \[CenterEllipsis]before the\cdotrule (order matters, so\cdotsis not caught by the shorter\cdotrule), with the same(?![a-zA-Z])letter-boundary guard. The primary parser passes⋯through unchanged in every context.walkerMath): post-mapExportLaTeX's output,\[CenterEllipsis] -> \cdots(plus\[VerticalEllipsis] -> \vdots,\[DescendingEllipsis] -> \ddotsfor the same reverse gap), using the trailing-space terminatorExportLaTeXitself emits for\ldots/\cdot.Scope is
\cdots.\vdots/\ddotsalready render correctly forward (they contain no\cdot), but shared the reverse gap; the inverse map now round-trips them to their commands too.Verification
texBoxes["a\\cdots b"]->a ⋯ b, no\[CenterDot]; round-trips to$a\cdots b$, and is idempotent (twin === twin2on the pure-\cdotsfamily)..nbfor the repro holds\[CenterEllipsis]boxes and zero CenterDot-adjacent-to-a-letter.\ldots/\dots-> …,\cdotalone -> ·, anda\cdot b + c\cdots dkeeps both correct in one expression.tests.wls333/333 (one regression test added),check.wlsclean.\ldotsworkaround and all other math intact).Separate, unrelated observation found while testing:
\mathrm{Tr}serializes back throughExportLaTeXas\Tr, which is not valid LaTeX and does not re-parse on a second build (the bracketed content is dropped and the span becomes an inline code span). That is an independent\mathrm{Tr}<->\Trround-trip issue, orthogonal to\cdots.Diff (uncommitted working-tree change)
Cross-refs: #11, #13, #25, #61.