diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a6aca..fdd6b34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### 더함 diff --git a/src/hwpx/equation/mathml.py b/src/hwpx/equation/mathml.py index 097a2ff..97ce5c0 100644 --- a/src/hwpx/equation/mathml.py +++ b/src/hwpx/equation/mathml.py @@ -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): @@ -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", ] diff --git a/src/hwpx/equation/render.py b/src/hwpx/equation/render.py index 33b53e8..8710da7 100644 --- a/src/hwpx/equation/render.py +++ b/src/hwpx/equation/render.py @@ -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 렌더" @@ -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: diff --git a/src/hwpx/equation/tokens.py b/src/hwpx/equation/tokens.py index 5a08030..d6f6066 100644 --- a/src/hwpx/equation/tokens.py +++ b/src/hwpx/equation/tokens.py @@ -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", @@ -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", diff --git a/tests/test_equation_authoring.py b/tests/test_equation_authoring.py index 22ba251..87a6c82 100644 --- a/tests/test_equation_authoring.py +++ b/tests/test_equation_authoring.py @@ -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"), @@ -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", ] diff --git a/tests/test_equation_converter.py b/tests/test_equation_converter.py index b471387..89b052d 100644 --- a/tests/test_equation_converter.py +++ b/tests/test_equation_converter.py @@ -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)" diff --git a/tests/test_equation_render.py b/tests/test_equation_render.py index c8b1973..07d9bc6 100644 --- a/tests/test_equation_render.py +++ b/tests/test_equation_render.py @@ -3,6 +3,8 @@ from __future__ import annotations +from xml.etree import ElementTree + import pytest from hwpx.equation import ( @@ -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