"""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('\\frac{a}{b}') assert "$$\\frac{a}{b}$$" in out def test_inline_math(self): out = to_typst_markup("x^2") assert "$x^2$" in out def test_multiple_math_blocks(self): out = to_typst_markup("See a+b and c-d") 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("hello") def test_strong(self): assert "**world**" in to_typst_markup("world") def test_italic(self): assert "_hi_" in to_typst_markup("hi") def test_em(self): assert "_em_" in to_typst_markup("em") def test_superscript_outside_math(self): out = to_typst_markup("x2") assert "^2^" in out def test_subscript_outside_math(self): out = to_typst_markup("H2O") assert "~2~" in out def test_bold_with_italic_inside(self): out = to_typst_markup("bold and italic") 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 a < b") 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("text") # Unknown tags stripped assert "" not in out assert "text" in out class TestMathAndProseEquation: def test_mixed_prose_and_math(self): out = to_typst_markup( "Nếu ax2 + 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 \\frac{a+c}{b}', 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("arrow.r.double dot.triple") assert "$arrow.r.double dot.triple$" in out def test_breaks_unknown_symbol_modifiers(self): out = to_typst_native("plus.unknown") 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("page_index = k") assert '$upright("page_index") = k$' in out def test_preserves_simple_subscripts(self): out = to_typst_native("x_i + a_1") 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("page_index = k") assert out == '$upright("page_index") = k$' def test_existing_upright_quotes_untouched(self): out = to_typst_native('upright("page_index") = k') 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('x = "pairs with" y') 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('TP(t) = #{"pairs" IoU >= t}') 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("TP(t) = 1") assert 'upright("TP")(t)' in out def test_known_function_call_kept(self): out = to_typst_native("frac(a, b)") assert "$frac(a, b)$" in out def test_known_identifier_subscript_kept(self): out = to_typst_native("sigma_x^2") 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("_(x)") assert '$""_(x)$' in out def test_trailing_attach_gets_empty_script(self): out = to_typst_native("x_") 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('x = "unclosed') assert '\\"' in out assert out.count('"') % 2 == 0 or '\\"' in out def test_typst_block_math_idempotent(self): out = to_typst_native("page $page_index$ = k") 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("Derivatives 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 a\nb more") assert "$a\nb$" in out # no backslash injected inside math