Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

모든 중요한 변경 사항은 이 문서에 기록됩니다. 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)과 [Semantic Versioning](https://semver.org/lang/ko/)을 따릅니다.

## [Unreleased]

### 고침

- EqEdit의 공식 `TRIANGLE` 토큰과 실문서에서 관측된 소문자 `triangle` 토큰을
`\triangle`로 변환한다. 미리보기 MathML에서도 삼각형을 식별자 문자열이 아닌
수학 연산자 기호로 보존하며, 혼합 대소문자나 더 긴 식별자는 해석하지 않는다.

## [6.3.0] - 2026-08-19

### 더함
Expand Down
23 changes: 23 additions & 0 deletions src/hwpx/equation/mathml.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@

from __future__ import annotations

import re
from typing import Callable

from .tokens import EQEDIT_MATHML_OPERATOR_COMMANDS

# ``False`` marks a resolved-but-unavailable converter; ``None`` means "not yet
# probed" so the import is attempted lazily on first use.
_converter: Callable[[str], str] | bool | None = None
_LATEX_WORD_COMMAND_RE = re.compile(r"\\[A-Za-z]+")


class MathMLUnavailableError(RuntimeError):
Expand Down Expand Up @@ -62,8 +66,27 @@ def latex_to_mathml(latex: str) -> str:
raise ValueError(f"latex2mathml could not render fragment: {exc}") from exc


def eqedit_latex_to_mathml(latex: str) -> str:
"""Convert EqEdit-derived LaTeX while preserving known token roles.

``latex2mathml`` classifies plain ``\\triangle`` as an identifier. EqEdit's
token map identifies it as a mathematical symbol, so the MathML-only input
receives an operator wrapper before conversion. The returned/public LaTeX
is not rewritten, and serialized MathML is never patched afterward.
"""

def annotate_role(match: re.Match[str]) -> str:
command = match.group(0)
if command in EQEDIT_MATHML_OPERATOR_COMMANDS:
return rf"\mathop{{{command}}}"
return command

return latex_to_mathml(_LATEX_WORD_COMMAND_RE.sub(annotate_role, latex))


__all__ = [
"MathMLUnavailableError",
"eqedit_latex_to_mathml",
"latex2mathml_available",
"latex_to_mathml",
]
4 changes: 2 additions & 2 deletions src/hwpx/equation/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import html

from .eqedit import EquationConversionError, eqedit_to_latex
from .mathml import MathMLUnavailableError, latex_to_mathml
from .mathml import MathMLUnavailableError, eqedit_latex_to_mathml

# Fidelity labels surfaced in the preview (Constitution IX, honest reporting).
LABEL_MATHML = "수식 MathML 렌더"
Expand Down Expand Up @@ -63,7 +63,7 @@ def render_equation(script: str) -> EquationRender:
return _fallback_block("script", LABEL_SCRIPT, script, latex=None)

try:
mathml = latex_to_mathml(latex)
mathml = eqedit_latex_to_mathml(latex)
except MathMLUnavailableError:
return _fallback_block("latex", LABEL_LATEX_NO_LIB, latex, latex=latex)
except ValueError:
Expand Down
11 changes: 11 additions & 0 deletions src/hwpx/equation/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@
"partial": r"\partial",
"nabla": r"\nabla",
"angle": r"\angle",
# Hancom's symbol guide spells this token in uppercase, while legacy
# documents also contain the exact lowercase form. Mixed-case variants are
# intentionally not accepted because the reader uses exact token lookup.
"triangle": r"\triangle",
"TRIANGLE": r"\triangle",
"cdots": r"\cdots",
"ldots": r"\ldots",
"vdots": r"\vdots",
Expand Down Expand Up @@ -254,11 +259,17 @@
# Structural keywords handled directly by the parser (not simple substitution).
STRUCTURAL = frozenset({"over", "atop", "sqrt", "root", "of", "LEFT", "RIGHT", "left", "right"})

# LaTeX commands emitted from EqEdit operator tokens that latex2mathml otherwise
# classifies as identifiers. The render boundary uses this metadata only while
# producing MathML; the public LaTeX remains the canonical command above.
EQEDIT_MATHML_OPERATOR_COMMANDS = frozenset({OPERATORS["triangle"]})


__all__ = [
"ACCENTS",
"BIG_OPERATORS",
"DELIMITERS",
"EQEDIT_MATHML_OPERATOR_COMMANDS",
"FUNCTIONS",
"GREEK",
"MATRIX_ENVIRONMENTS",
Expand Down
2 changes: 2 additions & 0 deletions tests/test_equation_authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ class TestLatexToEqedit:
(r"$x + 1$", "x + 1"),
(r"\le \ge \ne", "leq geq neq"),
(r"a \times b \cdot c \div d", "a times b cdot c div d"),
(r"\triangle P_1 P_2 Q", "triangle P _{1} P _{2} Q"),
(r"\lim_{x \to 0} \frac{1}{x}", "lim _{x -> 0} {1} over {x}"),
(r"a \to b", "a -> b"),
(r"x \rightarrow y , u \leftarrow v", "x -> y , u leftarrow v"),
Expand Down Expand Up @@ -274,6 +275,7 @@ class TestRoundtripStability:
r"\begin{vmatrix} a & b \\ c & d \end{vmatrix}",
r"\log_{2} x + \ln y",
r"a \times b \pm c \mp d",
r"\triangle P_1 P_2 Q",
r"\infty + \partial + \nabla",
]

Expand Down
25 changes: 25 additions & 0 deletions tests/test_equation_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,31 @@ def test_times_and_relations() -> None:
assert eqedit_to_latex("a >= b") == r"a \geq b"


@pytest.mark.parametrize("token", ["triangle", "TRIANGLE"])
def test_triangle_symbol_uses_exact_evidence_backed_tokens(token: str) -> None:
assert eqedit_to_latex(f"{token} P_1 P_2 Q") == r"\triangle P_1 P_2 Q"


@pytest.mark.parametrize(
"script",
[
"Triangle PQR",
"triAngle PQR",
"triangles PQR",
"mytriangle PQR",
"triangleidentifier PQR",
"rmtriangle PQR",
"rm text",
],
)
def test_triangle_support_does_not_split_identifiers_or_rm_text(script: str) -> None:
assert eqedit_to_latex(script) == script


def test_existing_angle_symbol_remains_distinct_from_triangle() -> None:
assert eqedit_to_latex("angle ABC") == r"\angle ABC"


def test_parentheses_and_braces_passthrough() -> None:
assert eqedit_to_latex("( a + b )") == "( a + b )"
assert eqedit_to_latex("LEFT ( x RIGHT )") == r"\left( x \right)"
Expand Down
19 changes: 19 additions & 0 deletions tests/test_equation_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

from __future__ import annotations

from xml.etree import ElementTree

import pytest

from hwpx.equation import (
Expand All @@ -25,6 +27,23 @@ def test_success_path_renders_inline_mathml() -> None:
assert result.latex == r"\frac{\alpha}{\beta} + \pi"


@pytest.mark.parametrize("token", ["triangle", "TRIANGLE"])
def test_triangle_renders_as_mathml_operator_without_changing_latex(token: str) -> None:
pytest.importorskip("latex2mathml")
result = render_equation(f"{token} P_1 P_2 Q")
assert result.mode == "mathml"
assert result.latex == r"\triangle P_1 P_2 Q"
root = ElementTree.fromstring(result.html)
namespace = "{http://www.w3.org/1998/Math/MathML}"
triangle_operators = [
operator
for operator in root.iter(f"{namespace}mo")
if "△" in "".join(operator.itertext())
]
assert len(triangle_operators) == 1
assert "triangle" not in "".join(root.itertext()).lower()


def test_eqedit_failure_falls_back_to_original_script_block() -> None:
# A brace/frac bomb exceeds the depth guard -> EqEdit->LaTeX fails.
bomb = "{" * 200 + "x" + "}" * 200
Expand Down