Repository: google/latexify_py

Issue:
Wrong behavior around multiplication
The current implementation converts `a * b` into $ab$. This only works if the variables have only 1 character and doesn't generate a correct syntax for other cases:

* Long names: `abc * def` --> $abcdef$, which is hard to distinguish from other combination of variables.
* Unary operators: `x * -y` --> $x - y$, which is definitely wrong.

These cases require explicit multiply operator ( $\cdot$ or $\times$ ) between the operands.

I think the `Mult(left, right)` subtree should be converted to $left \cdot right$ *by default*, and try to avoid the operator only in the following cases:

- The left-hand side operand is a single character or a bracket (with or without unary operators and/or super/subscripts)
- The right-hand side operand is a single character or a bracket (with or without super/subscripts, *without* unary operators)

This requires fine-graind name processing, and may be applied after #82.


Comments:

Patch:
diff --git a/src/latexify/codegen/expression_codegen.py b/src/latexify/codegen/expression_codegen.py
index 9706e74..3c72219 100644
--- a/src/latexify/codegen/expression_codegen.py
+++ b/src/latexify/codegen/expression_codegen.py
@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import ast
+import re
 
 from latexify import analyzers, ast_utils, exceptions
 from latexify.codegen import codegen_utils, expression_rules, identifier_converter
@@ -406,12 +407,94 @@ class ExpressionCodegen(ast.NodeVisitor):
 
         return rf"\mathopen{{}}\left( {latex} \mathclose{{}}\right)"
 
+    _l_bracket_pattern = re.compile(r"^\\mathopen.*")
+    _r_bracket_pattern = re.compile(r".*\\mathclose[^ ]+$")
+    _r_word_pattern = re.compile(r"\\mathrm\{[^ ]+\}$")
+
+    def _should_remove_multiply_op(
+        self, l_latex: str, r_latex: str, l_expr: ast.expr, r_expr: ast.expr
+    ):
+        """Determine whether the multiply operator should be removed or not.
+
+        See also:
+        https://github.com/google/latexify_py/issues/89#issuecomment-1344967636
+
+        This is an ad-hoc implementation.
+        This function doesn't fully implements the above requirements, but only
+        essential ones necessary to release v0.3.
+        """
+
+        # NOTE(odashi): For compatibility with Python 3.7, we compare the generated
+        # caracter type directly to determine the "numeric" type.
+
+        if isinstance(l_expr, ast.Call):
+            l_type = "f"
+        elif self._r_bracket_pattern.match(l_latex):
+            l_type = "b"
+        elif self._r_word_pattern.match(l_latex):
+            l_type = "w"
+        elif l_latex[-1].isnumeric():
+            l_type = "n"
+        else:
+            le = l_expr
+            while True:
+                if isinstance(le, ast.UnaryOp):
+                    le = le.operand
+                elif isinstance(le, ast.BinOp):
+                    le = le.right
+                elif isinstance(le, ast.Compare):
+                    le = le.comparators[-1]
+                elif isinstance(le, ast.BoolOp):
+                    le = le.values[-1]
+                else:
+                    break
+            l_type = "a" if isinstance(le, ast.Name) and len(le.id) == 1 else "m"
+
+        if isinstance(r_expr, ast.Call):
+            r_type = "f"
+        elif self._l_bracket_pattern.match(r_latex):
+            r_type = "b"
+        elif r_latex.startswith("\\mathrm"):
+            r_type = "w"
+        elif r_latex[0].isnumeric():
+            r_type = "n"
+        else:
+            re = r_expr
+            while True:
+                if isinstance(re, ast.UnaryOp):
+                    if isinstance(re.op, ast.USub):
+                        # NOTE(odashi): Unary "-" always require \cdot.
+                        return False
+                    re = re.operand
+                elif isinstance(re, ast.BinOp):
+                    re = re.left
+                elif isinstance(re, ast.Compare):
+                    re = re.left
+                elif isinstance(re, ast.BoolOp):
+                    re = re.values[0]
+                else:
+                    break
+            r_type = "a" if isinstance(re, ast.Name) and len(re.id) == 1 else "m"
+
+        if r_type == "n":
+            return False
+        if l_type in "bn":
+            return True
+        if l_type in "am" and r_type in "am":
+            return True
+        return False
+
     def visit_BinOp(self, node: ast.BinOp) -> str:
         """Visit a BinOp node."""
         prec = expression_rules.get_precedence(node)
         rule = self._bin_op_rules[type(node.op)]
         lhs = self._wrap_binop_operand(node.left, prec, rule.operand_left)
         rhs = self._wrap_binop_operand(node.right, prec, rule.operand_right)
+
+        if type(node.op) in [ast.Mult, ast.MatMult]:
+            if self._should_remove_multiply_op(lhs, rhs, node.left, node.right):
+                return f"{rule.latex_left}{lhs} {rhs}{rule.latex_right}"
+
         return f"{rule.latex_left}{lhs}{rule.latex_middle}{rhs}{rule.latex_right}"
 
     def visit_UnaryOp(self, node: ast.UnaryOp) -> str:


Test patch:
diff --git a/src/integration_tests/algorithmic_style_test.py b/src/integration_tests/algorithmic_style_test.py
index 8cbe68e..96665cb 100644
--- a/src/integration_tests/algorithmic_style_test.py
+++ b/src/integration_tests/algorithmic_style_test.py
@@ -63,7 +63,7 @@ def test_collatz() -> None:
                     \If{$n \mathbin{\%} 2 = 0$}
                         \State $n \gets \left\lfloor\frac{n}{2}\right\rfloor$
                     \Else
-                        \State $n \gets 3 \cdot n + 1$
+                        \State $n \gets 3 n + 1$
                     \EndIf
                     \State $\mathrm{iterations} \gets \mathrm{iterations} + 1$
                 \EndWhile
@@ -80,7 +80,7 @@ def test_collatz() -> None:
         r" \hspace{2em} \mathbf{if} \ n \mathbin{\%} 2 = 0 \\"
         r" \hspace{3em} n \gets \left\lfloor\frac{n}{2}\right\rfloor \\"
         r" \hspace{2em} \mathbf{else} \\"
-        r" \hspace{3em} n \gets 3 \cdot n + 1 \\"
+        r" \hspace{3em} n \gets 3 n + 1 \\"
         r" \hspace{2em} \mathbf{end \ if} \\"
         r" \hspace{2em}"
         r" \mathrm{iterations} \gets \mathrm{iterations} + 1 \\"
diff --git a/src/integration_tests/regression_test.py b/src/integration_tests/regression_test.py
index 17b5d3c..7cb063d 100644
--- a/src/integration_tests/regression_test.py
+++ b/src/integration_tests/regression_test.py
@@ -11,10 +11,7 @@ def test_quadratic_solution() -> None:
     def solve(a, b, c):
         return (-b + math.sqrt(b**2 - 4 * a * c)) / (2 * a)
 
-    latex = (
-        r"\mathrm{solve}(a, b, c) ="
-        r" \frac{-b + \sqrt{ b^{2} - 4 \cdot a \cdot c }}{2 \cdot a}"
-    )
+    latex = r"\mathrm{solve}(a, b, c) =" r" \frac{-b + \sqrt{ b^{2} - 4 a c }}{2 a}"
     integration_utils.check_function(solve, latex)
 
 
@@ -47,7 +44,7 @@ def test_x_times_beta() -> None:
         xtimesbeta, latex_without_symbols, use_math_symbols=False
     )
 
-    latex_with_symbols = r"\mathrm{xtimesbeta}(x, \beta) = x \cdot \beta"
+    latex_with_symbols = r"\mathrm{xtimesbeta}(x, \beta) = x \beta"
     integration_utils.check_function(
         xtimesbeta, latex_with_symbols, use_math_symbols=True
     )
@@ -145,7 +142,7 @@ def test_nested_function() -> None:
     def nested(x):
         return 3 * x
 
-    integration_utils.check_function(nested, r"\mathrm{nested}(x) = 3 \cdot x")
+    integration_utils.check_function(nested, r"\mathrm{nested}(x) = 3 x")
 
 
 def test_double_nested_function() -> None:
@@ -155,7 +152,7 @@ def test_double_nested_function() -> None:
 
         return inner
 
-    integration_utils.check_function(nested(3), r"\mathrm{inner}(y) = x \cdot y")
+    integration_utils.check_function(nested(3), r"\mathrm{inner}(y) = x y")
 
 
 def test_reduce_assignments() -> None:
@@ -165,11 +162,11 @@ def test_reduce_assignments() -> None:
 
     integration_utils.check_function(
         f,
-        r"\begin{array}{l} a = x + x \\ f(x) = 3 \cdot a \end{array}",
+        r"\begin{array}{l} a = x + x \\ f(x) = 3 a \end{array}",
     )
     integration_utils.check_function(
         f,
-        r"f(x) = 3 \cdot \mathopen{}\left( x + x \mathclose{}\right)",
+        r"f(x) = 3 \mathopen{}\left( x + x \mathclose{}\right)",
         reduce_assignments=True,
     )
 
@@ -184,7 +181,7 @@ def test_reduce_assignments_double() -> None:
         r"\begin{array}{l}"
         r" a = x^{2} \\"
         r" b = a + a \\"
-        r" f(x) = 3 \cdot b"
+        r" f(x) = 3 b"
         r" \end{array}"
     )
 
@@ -192,7 +189,7 @@ def test_reduce_assignments_double() -> None:
     integration_utils.check_function(f, latex_without_option, reduce_assignments=False)
     integration_utils.check_function(
         f,
-        r"f(x) = 3 \cdot \mathopen{}\left( x^{2} + x^{2} \mathclose{}\right)",
+        r"f(x) = 3 \mathopen{}\left( x^{2} + x^{2} \mathclose{}\right)",
         reduce_assignments=True,
     )
 
@@ -228,7 +225,7 @@ def test_sub_bracket() -> None:
         r"\mathrm{solve}(a, b) ="
         r" \frac{a + b - b}{a - b} - \mathopen{}\left("
         r" a + b \mathclose{}\right) - \mathopen{}\left("
-        r" a - b \mathclose{}\right) - a \cdot b"
+        r" a - b \mathclose{}\right) - a b"
     )
     integration_utils.check_function(solve, latex)
 
diff --git a/src/latexify/codegen/expression_codegen_test.py b/src/latexify/codegen/expression_codegen_test.py
index 0b90915..5eb999b 100644
--- a/src/latexify/codegen/expression_codegen_test.py
+++ b/src/latexify/codegen/expression_codegen_test.py
@@ -454,8 +454,8 @@ def test_if_then_else(code: str, latex: str) -> None:
     [
         # x op y
         ("x**y", r"x^{y}"),
-        ("x * y", r"x \cdot y"),
-        ("x @ y", r"x \cdot y"),
+        ("x * y", r"x y"),
+        ("x @ y", r"x y"),
         ("x / y", r"\frac{x}{y}"),
         ("x // y", r"\left\lfloor\frac{x}{y}\right\rfloor"),
         ("x % y", r"x \mathbin{\%} y"),
@@ -468,8 +468,8 @@ def test_if_then_else(code: str, latex: str) -> None:
         ("x | y", R"x \mathbin{|} y"),
         # (x op y) op z
         ("(x**y)**z", r"\mathopen{}\left( x^{y} \mathclose{}\right)^{z}"),
-        ("(x * y) * z", r"x \cdot y \cdot z"),
-        ("(x @ y) @ z", r"x \cdot y \cdot z"),
+        ("(x * y) * z", r"x y z"),
+        ("(x @ y) @ z", r"x y z"),
         ("(x / y) / z", r"\frac{\frac{x}{y}}{z}"),
         (
             "(x // y) // z",
@@ -485,8 +485,8 @@ def test_if_then_else(code: str, latex: str) -> None:
         ("(x | y) | z", r"x \mathbin{|} y \mathbin{|} z"),
         # x op (y op z)
         ("x**(y**z)", r"x^{y^{z}}"),
-        ("x * (y * z)", r"x \cdot y \cdot z"),
-        ("x @ (y @ z)", r"x \cdot y \cdot z"),
+        ("x * (y * z)", r"x y z"),
+        ("x @ (y @ z)", r"x y z"),
         ("x / (y / z)", r"\frac{x}{\frac{y}{z}}"),
         (
             "x // (y // z)",
@@ -504,9 +504,9 @@ def test_if_then_else(code: str, latex: str) -> None:
         ("x ^ (y ^ z)", r"x \oplus y \oplus z"),
         ("x | (y | z)", r"x \mathbin{|} y \mathbin{|} z"),
         # x OP y op z
-        ("x**y * z", r"x^{y} \cdot z"),
-        ("x * y + z", r"x \cdot y + z"),
-        ("x @ y + z", r"x \cdot y + z"),
+        ("x**y * z", r"x^{y} z"),
+        ("x * y + z", r"x y + z"),
+        ("x @ y + z", r"x y + z"),
         ("x / y + z", r"\frac{x}{y} + z"),
         ("x // y + z", r"\left\lfloor\frac{x}{y}\right\rfloor + z"),
         ("x % y + z", r"x \mathbin{\%} y + z"),
@@ -517,7 +517,7 @@ def test_if_then_else(code: str, latex: str) -> None:
         ("x & y ^ z", r"x \mathbin{\&} y \oplus z"),
         ("x ^ y | z", r"x \oplus y \mathbin{|} z"),
         # x OP (y op z)
-        ("x**(y * z)", r"x^{y \cdot z}"),
+        ("x**(y * z)", r"x^{y z}"),
         ("x * (y + z)", r"x \cdot \mathopen{}\left( y + z \mathclose{}\right)"),
         ("x @ (y + z)", r"x \cdot \mathopen{}\left( y + z \mathclose{}\right)"),
         ("x / (y + z)", r"\frac{x}{y + z}"),
@@ -542,9 +542,9 @@ def test_if_then_else(code: str, latex: str) -> None:
             r"x \oplus \mathopen{}\left( y \mathbin{|} z \mathclose{}\right)",
         ),
         # x op y OP z
-        ("x * y**z", r"x \cdot y^{z}"),
-        ("x + y * z", r"x + y \cdot z"),
-        ("x + y @ z", r"x + y \cdot z"),
+        ("x * y**z", r"x y^{z}"),
+        ("x + y * z", r"x + y z"),
+        ("x + y @ z", r"x + y z"),
         ("x + y / z", r"x + \frac{y}{z}"),
         ("x + y // z", r"x + \left\lfloor\frac{y}{z}\right\rfloor"),
         ("x + y % z", r"x + y \mathbin{\%} z"),
@@ -555,9 +555,9 @@ def test_if_then_else(code: str, latex: str) -> None:
         ("x ^ y & z", r"x \oplus y \mathbin{\&} z"),
         ("x | y ^ z", r"x \mathbin{|} y \oplus z"),
         # (x op y) OP z
-        ("(x * y)**z", r"\mathopen{}\left( x \cdot y \mathclose{}\right)^{z}"),
-        ("(x + y) * z", r"\mathopen{}\left( x + y \mathclose{}\right) \cdot z"),
-        ("(x + y) @ z", r"\mathopen{}\left( x + y \mathclose{}\right) \cdot z"),
+        ("(x * y)**z", r"\mathopen{}\left( x y \mathclose{}\right)^{z}"),
+        ("(x + y) * z", r"\mathopen{}\left( x + y \mathclose{}\right) z"),
+        ("(x + y) @ z", r"\mathopen{}\left( x + y \mathclose{}\right) z"),
         ("(x + y) / z", r"\frac{x + y}{z}"),
         ("(x + y) // z", r"\left\lfloor\frac{x + y}{z}\right\rfloor"),
         ("(x + y) % z", r"\mathopen{}\left( x + y \mathclose{}\right) \mathbin{\%} z"),
@@ -600,8 +600,8 @@ def test_if_then_else(code: str, latex: str) -> None:
         # With UnaryOp
         ("x**-y", r"x^{-y}"),
         ("(-x)**y", r"\mathopen{}\left( -x \mathclose{}\right)^{y}"),
-        ("x * -y", r"x \cdot -y"),  # TODO(odashi): google/latexify_py#89
-        ("-x * y", r"-x \cdot y"),
+        ("x * -y", r"x \cdot -y"),
+        ("-x * y", r"-x y"),
         ("x / -y", r"\frac{x}{-y}"),
         ("-x / y", r"\frac{-x}{y}"),
         ("x + -y", r"x + -y"),
@@ -610,7 +610,7 @@ def test_if_then_else(code: str, latex: str) -> None:
         ("x**(y == z)", r"x^{y = z}"),
         ("(x == y)**z", r"\mathopen{}\left( x = y \mathclose{}\right)^{z}"),
         ("x * (y == z)", r"x \cdot \mathopen{}\left( y = z \mathclose{}\right)"),
-        ("(x == y) * z", r"\mathopen{}\left( x = y \mathclose{}\right) \cdot z"),
+        ("(x == y) * z", r"\mathopen{}\left( x = y \mathclose{}\right) z"),
         ("x / (y == z)", r"\frac{x}{y = z}"),
         ("(x == y) / z", r"\frac{x = y}{z}"),
         ("x + (y == z)", r"x + \mathopen{}\left( y = z \mathclose{}\right)"),
@@ -619,7 +619,7 @@ def test_if_then_else(code: str, latex: str) -> None:
         ("x**(y and z)", r"x^{y \land z}"),
         ("(x and y)**z", r"\mathopen{}\left( x \land y \mathclose{}\right)^{z}"),
         ("x * (y and z)", r"x \cdot \mathopen{}\left( y \land z \mathclose{}\right)"),
-        ("(x and y) * z", r"\mathopen{}\left( x \land y \mathclose{}\right) \cdot z"),
+        ("(x and y) * z", r"\mathopen{}\left( x \land y \mathclose{}\right) z"),
         ("x / (y and z)", r"\frac{x}{y \land z}"),
         ("(x and y) / z", r"\frac{x \land y}{z}"),
         ("x + (y and z)", r"x + \mathopen{}\left( y \land z \mathclose{}\right)"),
@@ -991,3 +991,96 @@ def test_transpose(code: str, latex: str) -> None:
     tree = ast_utils.parse_expr(code)
     assert isinstance(tree, ast.Call)
     assert expression_codegen.ExpressionCodegen().visit(tree) == latex
+
+
+# Check list for #89.
+# https://github.com/google/latexify_py/issues/89#issuecomment-1344967636
+@pytest.mark.parametrize(
+    "left,right,latex",
+    [
+        ("2", "3", r"2 \cdot 3"),
+        ("2", "y", "2 y"),
+        ("2", "beta", r"2 \beta"),
+        ("2", "bar", r"2 \mathrm{bar}"),
+        ("2", "g(y)", r"2 g \mathopen{}\left( y \mathclose{}\right)"),
+        ("2", "(u + v)", r"2 \mathopen{}\left( u + v \mathclose{}\right)"),
+        ("x", "3", r"x \cdot 3"),
+        ("x", "y", "x y"),
+        ("x", "beta", r"x \beta"),
+        ("x", "bar", r"x \cdot \mathrm{bar}"),
+        ("x", "g(y)", r"x \cdot g \mathopen{}\left( y \mathclose{}\right)"),
+        ("x", "(u + v)", r"x \cdot \mathopen{}\left( u + v \mathclose{}\right)"),
+        ("alpha", "3", r"\alpha \cdot 3"),
+        ("alpha", "y", r"\alpha y"),
+        ("alpha", "beta", r"\alpha \beta"),
+        ("alpha", "bar", r"\alpha \cdot \mathrm{bar}"),
+        ("alpha", "g(y)", r"\alpha \cdot g \mathopen{}\left( y \mathclose{}\right)"),
+        (
+            "alpha",
+            "(u + v)",
+            r"\alpha \cdot \mathopen{}\left( u + v \mathclose{}\right)",
+        ),
+        ("foo", "3", r"\mathrm{foo} \cdot 3"),
+        ("foo", "y", r"\mathrm{foo} \cdot y"),
+        ("foo", "beta", r"\mathrm{foo} \cdot \beta"),
+        ("foo", "bar", r"\mathrm{foo} \cdot \mathrm{bar}"),
+        (
+            "foo",
+            "g(y)",
+            r"\mathrm{foo} \cdot g \mathopen{}\left( y \mathclose{}\right)",
+        ),
+        (
+            "foo",
+            "(u + v)",
+            r"\mathrm{foo} \cdot \mathopen{}\left( u + v \mathclose{}\right)",
+        ),
+        ("f(x)", "3", r"f \mathopen{}\left( x \mathclose{}\right) \cdot 3"),
+        ("f(x)", "y", r"f \mathopen{}\left( x \mathclose{}\right) \cdot y"),
+        ("f(x)", "beta", r"f \mathopen{}\left( x \mathclose{}\right) \cdot \beta"),
+        (
+            "f(x)",
+            "bar",
+            r"f \mathopen{}\left( x \mathclose{}\right) \cdot \mathrm{bar}",
+        ),
+        (
+            "f(x)",
+            "g(y)",
+            r"f \mathopen{}\left( x \mathclose{}\right)"
+            r" \cdot g \mathopen{}\left( y \mathclose{}\right)",
+        ),
+        (
+            "f(x)",
+            "(u + v)",
+            r"f \mathopen{}\left( x \mathclose{}\right)"
+            r" \cdot \mathopen{}\left( u + v \mathclose{}\right)",
+        ),
+        ("(s + t)", "3", r"\mathopen{}\left( s + t \mathclose{}\right) \cdot 3"),
+        ("(s + t)", "y", r"\mathopen{}\left( s + t \mathclose{}\right) y"),
+        ("(s + t)", "beta", r"\mathopen{}\left( s + t \mathclose{}\right) \beta"),
+        (
+            "(s + t)",
+            "bar",
+            r"\mathopen{}\left( s + t \mathclose{}\right) \mathrm{bar}",
+        ),
+        (
+            "(s + t)",
+            "g(y)",
+            r"\mathopen{}\left( s + t \mathclose{}\right)"
+            r" g \mathopen{}\left( y \mathclose{}\right)",
+        ),
+        (
+            "(s + t)",
+            "(u + v)",
+            r"\mathopen{}\left( s + t \mathclose{}\right)"
+            r" \mathopen{}\left( u + v \mathclose{}\right)",
+        ),
+    ],
+)
+def test_remove_multiply(left: str, right: str, latex: str) -> None:
+    for op in ["*", "@"]:
+        tree = ast_utils.parse_expr(f"{left} {op} {right}")
+        assert isinstance(tree, ast.BinOp)
+        assert (
+            expression_codegen.ExpressionCodegen(use_math_symbols=True).visit(tree)
+            == latex
+        )
diff --git a/src/latexify/codegen/function_codegen_match_test.py b/src/latexify/codegen/function_codegen_match_test.py
index 2467f94..87594ec 100644
--- a/src/latexify/codegen/function_codegen_match_test.py
+++ b/src/latexify/codegen/function_codegen_match_test.py
@@ -29,7 +29,7 @@ def test_functiondef_match() -> None:
         r"f(x) ="
         r" \left\{ \begin{array}{ll}"
         r" 1, & \mathrm{if} \ x = 0 \\"
-        r" 3 \cdot x, & \mathrm{otherwise}"
+        r" 3 x, & \mathrm{otherwise}"
         r" \end{array} \right."
     )
     assert function_codegen.FunctionCodegen().visit(tree) == expected
diff --git a/src/latexify/generate_latex_test.py b/src/latexify/generate_latex_test.py
index e9df79e..6f128b0 100644
--- a/src/latexify/generate_latex_test.py
+++ b/src/latexify/generate_latex_test.py
@@ -11,8 +11,8 @@ def test_get_latex_identifiers() -> None:
 
     identifiers = {"myfn": "f", "myvar": "x"}
 
-    latex_without_flag = r"\mathrm{myfn}(\mathrm{myvar}) = 3 \cdot \mathrm{myvar}"
-    latex_with_flag = r"f(x) = 3 \cdot x"
+    latex_without_flag = r"\mathrm{myfn}(\mathrm{myvar}) = 3 \mathrm{myvar}"
+    latex_with_flag = r"f(x) = 3 x"
 
     assert generate_latex.get_latex(myfn) == latex_without_flag
     assert generate_latex.get_latex(myfn, identifiers=identifiers) == latex_with_flag
@@ -46,8 +46,8 @@ def test_get_latex_reduce_assignments() -> None:
         y = 3 * x
         return y
 
-    latex_without_flag = r"\begin{array}{l} y = 3 \cdot x \\ f(x) = y \end{array}"
-    latex_with_flag = r"f(x) = 3 \cdot x"
+    latex_without_flag = r"\begin{array}{l} y = 3 x \\ f(x) = y \end{array}"
+    latex_with_flag = r"f(x) = 3 x"
 
     assert generate_latex.get_latex(f) == latex_without_flag
     assert generate_latex.get_latex(f, reduce_assignments=False) == latex_without_flag