Source code for md2star.preprocessing.math

"""LaTeX math handling for the intermediate Markdown.

Authors often wrap an inline LaTeX formula in backticks — either by reflex
(``\\`$x^2$\\```) or because they want to mix prose-style identifiers with
math (``\\`quality_threshold $\\in [0,1]$\\```). Pandoc treats backtick
content as a verbatim code span, so the math is rendered as monospace
literal text instead of as math.

This module rewrites those code spans into proper Pandoc math:

* A code span whose content is purely math is unwrapped: ``\\`$x^2$\\``` →
  ``$x^2$``.
* A code span that mixes plain text and math is merged into a single math
  expression: ``\\`quality_threshold $\\in [0,1]$\\``` →
  ``$\\text{quality threshold} \\in [0,1]$``. Underscores in the text
  portion are read as snake_case word separators and become spaces, since a
  literal ``_`` would mean subscript inside the resulting math context.

A code span with no math (just code/identifiers) is left untouched.

The module also exposes :data:`MATH_FORMULA_RE` so other passes (e.g. the
pipe-table soft-break inserter) can tokenize around math chunks and avoid
mutating them.


Author
------
[Warith HARCHAOUI](https://linkedin.com/in/warith-harchaoui/)
"""

from __future__ import annotations

import re

# Pandoc-supported inline math delimiters. Order matters: the longer
# ``$$..$$`` and ``\\[..\\]`` display-math forms must be tried before the
# shorter ``$..$`` / ``\\(..\\)`` inline forms so the longer pair wins.
MATH_FORMULA_RE = re.compile(
    r"\$\$[^$\n]+?\$\$"
    r"|\$[^$\n]+?\$"
    r"|\\\([^)\n]+?\\\)"
    r"|\\\[[^\]\n]+?\\\]"
)


# Same alternations as MATH_FORMULA_RE, but wrapped in a single capture
# group so ``re.split`` keeps the math chunks as odd-indexed elements.
_MATH_SPLIT_RE = re.compile(rf"({MATH_FORMULA_RE.pattern})")


# An inline backtick code span: a single ``\\``…\\``` run that does not
# straddle newlines and is non-empty.
_CODE_SPAN_RE = re.compile(r"`([^`\n]+)`")


def _strip_math_delims(chunk: str) -> str:
    """Return the content of a math chunk with its delimiters removed."""
    # Check the 2-char display delimiters ($$, \[) before the 1-char inline
    # ones so ``$$x$$`` strips both dollars, not just the outer pair.
    if chunk.startswith("$$") and chunk.endswith("$$"):
        return chunk[2:-2].strip()
    if chunk.startswith("$") and chunk.endswith("$"):
        return chunk[1:-1].strip()
    if chunk.startswith("\\(") and chunk.endswith("\\)"):
        return chunk[2:-2].strip()
    if chunk.startswith("\\[") and chunk.endswith("\\]"):
        return chunk[2:-2].strip()
    return chunk


def _wants_display_math(math_chunks: list[str]) -> bool:
    """True if any chunk uses display delimiters (``$$..$$`` or ``\\[..\\]``)."""
    return any(c.startswith("$$") or c.startswith("\\[") for c in math_chunks)


def _textify(raw: str) -> str:
    """Render a plain-text chunk for inclusion inside a math expression.

    Snake_case identifiers read more naturally as space-separated words, and
    a bare ``_`` inside a math context is parsed by LaTeX as a subscript
    operator. Replacing runs of underscores with a single space handles both
    issues at once.
    """
    text = raw.strip()
    if not text:
        return ""
    text = re.sub(r"_+", " ", text)
    return rf"\text{{{text}}}"


def _merge_code_span(content: str) -> str | None:
    """Merge text + math inside a code span into one math expression.

    Returns ``None`` when *content* contains no math chunk at all — the
    caller should leave the original code span untouched in that case.

    When the content is a single math chunk with no surrounding text the
    chunk is returned verbatim, preserving the user's original delimiter
    choice (``$..$``, ``\\(..\\)``, ``\\[..\\]``, ``$$..$$``). When the
    content mixes prose with math the result is unified under ``$..$`` (or
    ``$$..$$`` if any inner chunk was display-math), because the prose
    portion has to be folded in as ``\\text{}`` and can only live inside
    dollar-delimited math.
    """
    parts = _MATH_SPLIT_RE.split(content)
    if len(parts) < 3:
        return None  # no math match → not our concern

    # Even indices are plain text, odd indices are matched math chunks.
    has_prose = any(parts[i].strip() for i in range(0, len(parts), 2))
    math_chunks = [parts[i] for i in range(1, len(parts), 2)]

    # Pure math, no prose → return the chunk as-is so the original
    # delimiter style survives.
    if not has_prose and len(math_chunks) == 1:
        return math_chunks[0]

    delim = "$$" if _wants_display_math(math_chunks) else "$"

    # Walk the split parts in order, re-emitting each into the unified math
    # expression: even parts are prose (wrapped in \text{}), odd parts are the
    # inner math (delimiters stripped, since the whole thing gets re-wrapped).
    pieces: list[str] = []
    for idx, part in enumerate(parts):
        if idx % 2 == 0:
            rendered = _textify(part)
            # Skip empty text fragments (e.g. between adjacent math chunks).
            if rendered:
                pieces.append(rendered)
        else:
            inner = _strip_math_delims(part)
            if inner:
                pieces.append(inner)

    # Everything collapsed to nothing → signal "leave the span alone".
    if not pieces:
        return None
    return f"{delim}{' '.join(pieces)}{delim}"


[docs] def unwrap_math_in_code_spans(text: str) -> str: """Rewrite backtick code spans whose content contains LaTeX math. Pure-math spans are unwrapped; mixed text/math spans are merged into a single math expression with text portions wrapped in ``\\text{}``. Code spans with no math are returned verbatim. """ def _replace(match: re.Match) -> str: """Rewrite one matched code span, or leave it verbatim if it has no math. Parameters ---------- match : re.Match A match of :data:`_CODE_SPAN_RE`; group 1 is the span's inner text. Returns ------- str The merged math expression, or the original span (``match.group(0)``) when the content holds no LaTeX math. """ merged = _merge_code_span(match.group(1)) return merged if merged is not None else match.group(0) return _CODE_SPAN_RE.sub(_replace, text)