File size: 8,423 Bytes
f66643d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
"""Unit tests for pdf2zh.render.markup — HTML/LaTeX → Typst markup conversion."""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from pdf2zh.render.markup import parse_toc_line, to_typst_markup, to_typst_native


class TestMathConversion:
    def test_display_math(self):
        out = to_typst_markup('<math display="block">\\frac{a}{b}</math>')
        assert "$$\\frac{a}{b}$$" in out

    def test_inline_math(self):
        out = to_typst_markup("<math>x^2</math>")
        assert "$x^2$" in out

    def test_multiple_math_blocks(self):
        out = to_typst_markup("See <math>a+b</math> and <math>c-d</math>")
        assert "$a+b$" in out
        assert "$c-d$" in out

    def test_math_preserved_in_equation_mode(self):
        out = to_typst_markup("$a=b$", is_equation=True)
        # Already has $...$, should stay
        assert "$a=b$" in out

    def test_bare_latex_wrapped_in_equation_mode(self):
        out = to_typst_markup("We get \\frac{a}{b}", is_equation=True)
        assert "$" in out
        assert "\\frac{a}{b}" in out

    def test_bare_latex_not_wrapped_outside_equation(self):
        # In non-equation mode, bare LaTeX is not wrapped
        out = to_typst_markup("Some text \\frac{a}{b}", is_equation=False)
        # No wrapping — just passes through (stripped as unknown tag or kept)
        assert "\\frac{a}{b}" in out


class TestHtmlFormatting:
    def test_bold(self):
        assert "**hello**" in to_typst_markup("<b>hello</b>")

    def test_strong(self):
        assert "**world**" in to_typst_markup("<strong>world</strong>")

    def test_italic(self):
        assert "_hi_" in to_typst_markup("<i>hi</i>")

    def test_em(self):
        assert "_em_" in to_typst_markup("<em>em</em>")

    def test_superscript_outside_math(self):
        out = to_typst_markup("x<sup>2</sup>")
        assert "^2^" in out

    def test_subscript_outside_math(self):
        out = to_typst_markup("H<sub>2</sub>O")
        assert "~2~" in out

    def test_bold_with_italic_inside(self):
        out = to_typst_markup("<b>bold <i>and italic</i></b>")
        assert "**" in out
        assert "_" in out


class TestLiteralCharacters:
    def test_less_than_escaped(self):
        out = to_typst_markup("If a < b then")
        assert "\\<" in out

    def test_italic_variable_with_comparison(self):
        out = to_typst_markup("If <i>a</i> < <i>b</i>")
        assert "_a_" in out
        assert "_b_" in out
        assert "\\<" in out

    def test_plain_text_hash_escaped(self):
        out = to_typst_markup("Price: #100")
        assert "\\#100" in out

    def test_plain_text_at_escaped(self):
        out = to_typst_markup("Email @user")
        assert "\\@user" in out

    def test_no_false_tag_match(self):
        # A tag-like that isn't a known formatting tag
        out = to_typst_markup("<unknown>text</unknown>")
        # Unknown tags stripped
        assert "<unknown>" not in out
        assert "text" in out


class TestMathAndProseEquation:
    def test_mixed_prose_and_math(self):
        out = to_typst_markup(
            "Nếu ax<sup>2</sup> + bx + c = 0, thì x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}.",
            is_equation=True,
        )
        # Vietnamese prose preserved
        assert "Nếu" in out
        assert "thì" in out
        # Math wrapped
        assert "$" in out

    def test_equation_with_html_math_tags(self):
        out = to_typst_markup(
            'a(b + c) = ab + ac <math display="block">\\frac{a+c}{b}</math>',
            is_equation=True,
        )
        assert "$$\\frac{a+c}{b}$$" in out
        assert "a(b + c) = ab + ac" in out


class TestTypstNativeMath:
    def test_preserves_multilevel_symbol_modifiers(self):
        out = to_typst_native("<math>arrow.r.double dot.triple</math>")
        assert "$arrow.r.double dot.triple$" in out

    def test_breaks_unknown_symbol_modifiers(self):
        out = to_typst_native("<math>plus.unknown</math>")
        assert "plus.unknown" not in out
        assert "$plus u n k n o w n$" in out

    def test_quotes_long_underscore_identifiers(self):
        out = to_typst_native("<math>page_index = k</math>")
        assert '$upright("page_index") = k$' in out

    def test_preserves_simple_subscripts(self):
        out = to_typst_native("<math>x_i + a_1</math>")
        assert "$x_i + a_1$" in out


class TestMathSanitizer:
    """Regression tests: LLM math output must never break the Typst compile."""

    def test_no_double_wrap_of_underscore_identifiers(self):
        # Was: $upright("upright("page_index")")$ — nested unescaped quotes.
        out = to_typst_native("<math>page_index = k</math>")
        assert out == '$upright("page_index") = k$'

    def test_existing_upright_quotes_untouched(self):
        out = to_typst_native('<math>upright("page_index") = k</math>')
        assert out == '$upright("page_index") = k$'

    def test_quoted_strings_not_letter_split(self):
        # Was: "pairs with" → "p a i r s w i t h"
        out = to_typst_native('<math>x = "pairs with" y</math>')
        assert '"pairs with"' in out

    def test_bare_hash_escaped_in_math(self):
        # Bare # in Typst math starts a code expression → compile error.
        out = to_typst_native('<math>TP(t) = #{"pairs" IoU >= t}</math>')
        assert "\\#" in out
        assert '"pairs"' in out

    def test_unknown_function_name_quoted(self):
        # Bare TP(t) is 'unknown variable: TP' at compile time.
        out = to_typst_native("<math>TP(t) = 1</math>")
        assert 'upright("TP")(t)' in out

    def test_known_function_call_kept(self):
        out = to_typst_native("<math>frac(a, b)</math>")
        assert "$frac(a, b)$" in out

    def test_known_identifier_subscript_kept(self):
        out = to_typst_native("<math>sigma_x^2</math>")
        assert "$sigma_x^2$" in out

    def test_leading_attach_gets_empty_base(self):
        # $_(x)$ is 'unexpected underscore' — needs an empty base.
        out = to_typst_native("<math>_(x)</math>")
        assert '$""_(x)$' in out

    def test_trailing_attach_gets_empty_script(self):
        out = to_typst_native("<math>x_</math>")
        assert '$x_""$' in out

    def test_unmatched_quote_escaped(self):
        # A lone quote opens a string that swallows the rest of the source.
        out = to_typst_native('<math>x = "unclosed</math>')
        assert '\\"' in out
        assert out.count('"') % 2 == 0 or '\\"' in out

    def test_typst_block_math_idempotent(self):
        out = to_typst_native("<typst>page $page_index$ = k</typst>")
        assert 'page $upright("page_index")$ = k' == out


class TestTocLineParsing:
    def test_simple_entry(self):
        result = parse_toc_line("Introduction 1")
        assert result == ("Introduction", "1")

    def test_entry_with_dots(self):
        result = parse_toc_line("Chapter 1: Overview ....... 15")
        assert result is not None
        assert result[1] == "15"
        assert "Chapter 1" in result[0]

    def test_entry_with_bold_markup(self):
        result = parse_toc_line("<b>Derivatives</b> 174")
        assert result is not None
        assert result[1] == "174"

    def test_entry_no_page_number(self):
        result = parse_toc_line("Just a heading")
        assert result is None

    def test_empty_line(self):
        assert parse_toc_line("") is None

    def test_multipart_number(self):
        result = parse_toc_line("3.1 Derivatives of Polynomials 174")
        assert result is not None
        assert result[1] == "174"


class TestNewlineHandling:
    def test_lone_newline_becomes_hard_break(self):
        # A single '\n' must become a CommonMark hard break (backslash + newline)
        # so cmarker keeps the line break instead of collapsing it to a space.
        out = to_typst_markup("Ho Chi Minh City\nStudent group")
        assert "\\\n" in out
        assert out == "Ho Chi Minh City\\\nStudent group"

    def test_paragraph_break_preserved(self):
        # A blank line (double newline) stays a paragraph break, not a hard break.
        out = to_typst_markup("Para one.\n\nPara two.")
        assert "\\\n" not in out
        assert "\n\n" in out

    def test_newline_inside_math_untouched(self):
        out = to_typst_markup("text <math>a\nb</math> more")
        assert "$a\nb$" in out  # no backslash injected inside math