Skip to content

MarkdownToNotebook: \cdots renders as \[CenterDot] + a stray "s" ("· s") in a juxtaposition run (primary LaTeXMathParse mis-lex); no reverse map for \[CenterEllipsis] #68

Description

@mbahram

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions