fix: fix equation rendering by changing the toolchain to mathjax (#493)

* docs: update README and build guide

* fix: escape * and _ inside math to prevent markdown emphasis corruption

* fix: configure MathJax to use TeX (Computer Modern) font

* feat: enhance markdown processing with label and figure collection

* fix: remove duplicate bibliography directives from chapter summaries

References are already handled at the chapter level, so the
:bibliography: directives in summary pages are redundant and cause
rendering issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
anyin233
2026-03-12 06:21:56 +00:00
committed by GitHub
parent ec03af6862
commit 00db02dbfd
26 changed files with 642 additions and 1037 deletions

View File

@@ -1,134 +0,0 @@
from __future__ import annotations
import gzip
import hashlib
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from tools.ensure_mdbook_typst_math import (
ASSET_SHA256,
VERSION,
build_download_url,
ensure_binary,
resolve_asset_name,
resolve_binary_path,
resolve_version_path,
)
class ResolveAssetNameTests(unittest.TestCase):
def test_resolve_asset_name_for_supported_targets(self) -> None:
self.assertEqual(
resolve_asset_name(system="Darwin", machine="arm64"),
"mdbook-typst-math-aarch64-apple-darwin.gz",
)
self.assertEqual(
resolve_asset_name(system="Darwin", machine="x86_64"),
"mdbook-typst-math-x86_64-apple-darwin.gz",
)
self.assertEqual(
resolve_asset_name(system="Linux", machine="aarch64"),
"mdbook-typst-math-aarch64-unknown-linux-gnu.gz",
)
self.assertEqual(
resolve_asset_name(system="Linux", machine="AMD64"),
"mdbook-typst-math-x86_64-unknown-linux-gnu.gz",
)
self.assertEqual(
resolve_asset_name(system="Windows", machine="AMD64"),
"mdbook-typst-math-x86_64-pc-windows-msvc.exe",
)
def test_resolve_asset_name_rejects_unsupported_targets(self) -> None:
with self.assertRaises(ValueError):
resolve_asset_name(system="Linux", machine="riscv64")
class EnsureBinaryTests(unittest.TestCase):
def test_ensure_binary_downloads_and_extracts_gzip_release(self) -> None:
payload = b"linux-binary"
asset_name = "mdbook-typst-math-x86_64-unknown-linux-gnu.gz"
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
urls: list[str] = []
def fake_downloader(url: str) -> bytes:
urls.append(url)
return gzip.compress(payload)
with patch.dict(ASSET_SHA256, {asset_name: hashlib.sha256(gzip.compress(payload)).hexdigest()}):
binary_path = ensure_binary(
output_dir,
system="Linux",
machine="x86_64",
downloader=fake_downloader,
)
self.assertEqual(binary_path, resolve_binary_path(output_dir, VERSION, asset_name))
self.assertEqual(binary_path.name, "mdbook-typst-math")
self.assertEqual(binary_path.read_bytes(), payload)
self.assertEqual(resolve_version_path(output_dir).read_text(encoding="utf-8"), VERSION)
self.assertEqual(urls, [build_download_url(VERSION, asset_name)])
self.assertTrue(os.access(binary_path, os.X_OK))
def test_ensure_binary_uses_cached_file_without_downloading(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
asset_name = "mdbook-typst-math-x86_64-unknown-linux-gnu.gz"
cached_binary = resolve_binary_path(output_dir, VERSION, asset_name)
output_dir.mkdir(parents=True, exist_ok=True)
cached_binary.write_bytes(b"cached")
cached_binary.chmod(0o755)
resolve_version_path(output_dir).write_text(VERSION, encoding="utf-8")
def fail_downloader(_: str) -> bytes:
raise AssertionError("downloader should not be called for cached binary")
binary_path = ensure_binary(
output_dir,
system="Linux",
machine="x86_64",
downloader=fail_downloader,
)
self.assertEqual(binary_path, cached_binary)
self.assertEqual(binary_path.read_bytes(), b"cached")
def test_ensure_binary_keeps_windows_extension(self) -> None:
payload = b"windows-binary"
asset_name = "mdbook-typst-math-x86_64-pc-windows-msvc.exe"
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
def fake_downloader(_: str) -> bytes:
return payload
with patch.dict(ASSET_SHA256, {asset_name: hashlib.sha256(payload).hexdigest()}):
binary_path = ensure_binary(
output_dir,
system="Windows",
machine="AMD64",
downloader=fake_downloader,
)
self.assertEqual(binary_path.name, "mdbook-typst-math.exe")
self.assertEqual(binary_path.read_bytes(), payload)
def test_ensure_binary_rejects_checksum_mismatch(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
with self.assertRaises(ValueError):
ensure_binary(
Path(tmpdir),
system="Linux",
machine="x86_64",
downloader=lambda _: gzip.compress(b"bad-binary"),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,38 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
BOOK_PATHS = (
REPO_ROOT / "book.toml",
REPO_ROOT / "books" / "zh" / "book.toml",
)
BUILD_SCRIPTS = (
REPO_ROOT / "build_mdbook.sh",
REPO_ROOT / "build_mdbook_zh.sh",
)
class MdBookTypstMathConfigTests(unittest.TestCase):
def test_books_use_typst_math_without_mathjax(self) -> None:
for path in BOOK_PATHS:
config = path.read_text(encoding="utf-8")
self.assertIn("[preprocessor.typst-math]", config, path.as_posix())
self.assertIn("theme/typst.css", config, path.as_posix())
self.assertNotIn("mathjax-support = true", config, path.as_posix())
def test_build_scripts_bootstrap_prebuilt_typst_math_binary(self) -> None:
for path in BUILD_SCRIPTS:
script = path.read_text(encoding="utf-8")
self.assertIn("ensure_mdbook_typst_math.py", script, path.as_posix())
self.assertIn("MDBOOK_TYPST_MATH_BIN_DIR", script, path.as_posix())
self.assertIn("export PATH=", script, path.as_posix())
self.assertNotIn("cargo install mdbook-typst-math", script, path.as_posix())
if __name__ == "__main__":
unittest.main()

View File

@@ -4,7 +4,17 @@ import tempfile
import unittest
from pathlib import Path
from tools.prepare_mdbook import build_title_cache, rewrite_markdown, write_summary
from tools.prepare_mdbook import (
_relative_chapter_path,
build_title_cache,
collect_figure_labels,
collect_labels,
convert_math_to_mathjax,
normalize_directives,
process_figure_captions,
rewrite_markdown,
write_summary,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -233,5 +243,287 @@ Reference :cite:`smith2024`.
self.assertIn("width: 100%;", frontpage)
class CollectLabelsTests(unittest.TestCase):
def test_standalone_label(self) -> None:
md = ":label:`my_fig`\n"
self.assertEqual(collect_labels(md), ["my_fig"])
def test_inline_table_label(self) -> None:
md = "|:label:`tbl`|||\n"
self.assertEqual(collect_labels(md), ["tbl"])
def test_escaped_underscores(self) -> None:
md = ":label:`ros2\\_topics`\n"
self.assertEqual(collect_labels(md), ["ros2\\_topics"])
def test_empty(self) -> None:
md = "No labels here.\n"
self.assertEqual(collect_labels(md), [])
def test_multiple_labels(self) -> None:
md = ":label:`fig1`\nsome text\n:label:`fig2`\n"
self.assertEqual(collect_labels(md), ["fig1", "fig2"])
class LabelToAnchorTests(unittest.TestCase):
def test_standalone_label_becomes_anchor(self) -> None:
result = normalize_directives(":label:`ROS2_arch`\n")
self.assertIn('<a id="ROS2_arch"></a>', result)
self.assertNotIn(":label:", result)
def test_table_row_label_becomes_anchor(self) -> None:
result = normalize_directives("|:label:`tbl`|||\n")
self.assertIn('|<a id="tbl"></a>|||', result)
def test_width_line_removed(self) -> None:
result = normalize_directives(":width:`800px`\n")
self.assertNotIn(":width:", result)
self.assertNotIn("800px", result)
class NumrefToLinkTests(unittest.TestCase):
def test_same_file_link(self) -> None:
ref_map = {"my_fig": "chapter/page.md"}
result = normalize_directives(
"See :numref:`my_fig`.\n",
ref_label_map=ref_map,
current_source_path="chapter/page.md",
)
self.assertIn("[my_fig](#my_fig)", result)
def test_cross_file_link(self) -> None:
ref_map = {"my_fig": "other_ch/file.md"}
result = normalize_directives(
"See :numref:`my_fig`.\n",
ref_label_map=ref_map,
current_source_path="chapter/page.md",
)
self.assertIn("[my_fig](../other_ch/file.md#my_fig)", result)
def test_unknown_label_fallback(self) -> None:
result = normalize_directives(
"See :numref:`unknown`.\n",
ref_label_map={},
current_source_path="chapter/page.md",
)
self.assertIn("`unknown`", result)
self.assertNotIn("[unknown]", result)
def test_no_ref_map_fallback(self) -> None:
result = normalize_directives("See :numref:`foo`.\n")
self.assertIn("`foo`", result)
def test_escaped_underscores_in_numref(self) -> None:
ref_map = {"ros2\\_topics": "chapter/ros.md"}
result = normalize_directives(
"See :numref:`ros2\\_topics`.\n",
ref_label_map=ref_map,
current_source_path="chapter/ros.md",
)
# _strip_latex_escapes_outside_math removes \_ → _, producing consistent IDs
self.assertIn("[ros2_topics](#ros2_topics)", result)
class RelativeChapterPathTests(unittest.TestCase):
def test_same_file(self) -> None:
self.assertEqual(_relative_chapter_path("ch/page.md", "ch/page.md"), "")
def test_same_dir(self) -> None:
result = _relative_chapter_path("ch/a.md", "ch/b.md")
self.assertEqual(result, "b.md")
def test_different_dir(self) -> None:
result = _relative_chapter_path("ch1/page.md", "ch2/other.md")
self.assertEqual(result, "../ch2/other.md")
class CollectFigureLabelsTests(unittest.TestCase):
def test_image_followed_by_label(self) -> None:
md = "![cap](img.png)\n:label:`fig1`\n"
self.assertEqual(collect_figure_labels(md), ["fig1"])
def test_image_with_width_and_label(self) -> None:
md = "![cap](img.png)\n:width:`800px`\n:label:`fig1`\n"
self.assertEqual(collect_figure_labels(md), ["fig1"])
def test_image_with_blank_lines(self) -> None:
md = "![cap](img.png)\n\n:width:`800px`\n\n:label:`fig1`\n"
self.assertEqual(collect_figure_labels(md), ["fig1"])
def test_table_label_not_collected(self) -> None:
md = "|:label:`tbl`|||\n"
self.assertEqual(collect_figure_labels(md), [])
def test_standalone_label_without_image(self) -> None:
md = "# Heading\n:label:`sec1`\n"
self.assertEqual(collect_figure_labels(md), [])
def test_multiple_figures(self) -> None:
md = "![a](a.png)\n:label:`f1`\n\n![b](b.png)\n:label:`f2`\n"
self.assertEqual(collect_figure_labels(md), ["f1", "f2"])
class ProcessFigureCaptionsTests(unittest.TestCase):
def test_figure_with_number_and_caption(self) -> None:
md = "![量化原理](img.png)\n:width:`800px`\n:label:`fig1`\n"
result = process_figure_captions(md, fig_number_map={"fig1": "8.1"})
self.assertIn('<a id="fig1"></a>', result)
self.assertIn("![量化原理](img.png)", result)
self.assertIn('<p align="center">图8.1 量化原理</p>', result)
self.assertNotIn(":width:", result)
self.assertNotIn(":label:", result)
def test_figure_without_number_map(self) -> None:
md = "![caption](img.png)\n:label:`fig1`\n"
result = process_figure_captions(md)
self.assertIn('<a id="fig1"></a>', result)
self.assertIn("![caption](img.png)", result)
self.assertIn('<p align="center">caption</p>', result)
def test_image_without_label_passthrough(self) -> None:
md = "![caption](img.png)\nSome text\n"
result = process_figure_captions(md)
self.assertIn("![caption](img.png)", result)
self.assertNotIn('<a id=', result)
self.assertNotIn('<p align="center">', result)
def test_figure_empty_caption(self) -> None:
md = "![](img.png)\n:label:`fig1`\n"
result = process_figure_captions(md, fig_number_map={"fig1": "1.1"})
self.assertIn('<p align="center">图1.1</p>', result)
class NumrefWithFigureNumberTests(unittest.TestCase):
def test_numref_shows_figure_number(self) -> None:
result = normalize_directives(
"See :numref:`my_fig`.\n",
ref_label_map={"my_fig": "ch/page.md"},
current_source_path="ch/page.md",
fig_number_map={"my_fig": "8.1"},
)
self.assertIn("[图8.1](#my_fig)", result)
def test_numref_cross_file_with_figure_number(self) -> None:
result = normalize_directives(
"See :numref:`my_fig`.\n",
ref_label_map={"my_fig": "other/page.md"},
current_source_path="ch/page.md",
fig_number_map={"my_fig": "3.2"},
)
self.assertIn("[图3.2](../other/page.md#my_fig)", result)
def test_numref_without_figure_number_shows_name(self) -> None:
result = normalize_directives(
"See :numref:`tbl`.\n",
ref_label_map={"tbl": "ch/page.md"},
current_source_path="ch/page.md",
fig_number_map={},
)
self.assertIn("[tbl](#tbl)", result)
class LabelNumrefIntegrationTests(unittest.TestCase):
def test_rewrite_markdown_with_label_map(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
page = Path(tmpdir) / "chapter" / "page.md"
page.parent.mkdir()
page.write_text(
"# Title\n\n:label:`my_fig`\n\nSee :numref:`my_fig`.\n",
encoding="utf-8",
)
rewritten = rewrite_markdown(
page.read_text(encoding="utf-8"),
page.resolve(),
{page.resolve(): "Title"},
ref_label_map={"my_fig": "chapter/page.md"},
current_source_path="chapter/page.md",
)
self.assertIn('<a id="my_fig"></a>', rewritten)
self.assertIn("[my_fig](#my_fig)", rewritten)
def test_rewrite_markdown_cross_file_numref(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
page = Path(tmpdir) / "ch1" / "page.md"
page.parent.mkdir()
page.write_text(
"# Title\n\nSee :numref:`other_fig`.\n",
encoding="utf-8",
)
rewritten = rewrite_markdown(
page.read_text(encoding="utf-8"),
page.resolve(),
{page.resolve(): "Title"},
ref_label_map={"other_fig": "ch2/file.md"},
current_source_path="ch1/page.md",
)
self.assertIn("[other_fig](../ch2/file.md#other_fig)", rewritten)
def test_rewrite_markdown_figure_with_number_and_caption(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
page = Path(tmpdir) / "ch" / "page.md"
page.parent.mkdir()
page.write_text(
"# Title\n\n![量化原理](img.png)\n:width:`800px`\n:label:`qfig`\n\nSee :numref:`qfig`.\n",
encoding="utf-8",
)
rewritten = rewrite_markdown(
page.read_text(encoding="utf-8"),
page.resolve(),
{page.resolve(): "Title"},
ref_label_map={"qfig": "ch/page.md"},
current_source_path="ch/page.md",
fig_number_map={"qfig": "8.1"},
)
self.assertIn('<a id="qfig"></a>', rewritten)
self.assertIn("![量化原理](img.png)", rewritten)
self.assertIn('<p align="center">图8.1 量化原理</p>', rewritten)
self.assertIn("[图8.1](#qfig)", rewritten)
class ConvertMathToMathjaxTests(unittest.TestCase):
def test_display_math(self) -> None:
result = convert_math_to_mathjax("before $$x^2$$ after")
self.assertEqual(result, "before \\\\[x^2\\\\] after")
def test_inline_math(self) -> None:
result = convert_math_to_mathjax("before $x^2$ after")
self.assertEqual(result, "before \\\\(x^2\\\\) after")
def test_backslash_doubling_inside_math(self) -> None:
result = convert_math_to_mathjax("$$a \\\\ b$$")
self.assertEqual(result, "\\\\[a \\\\\\\\ b\\\\]")
def test_math_inside_code_block_not_converted(self) -> None:
text = "```\n$x^2$\n```"
result = convert_math_to_mathjax(text)
self.assertEqual(result, text)
def test_math_inside_inline_code_not_converted(self) -> None:
text = "use `$x$` for math"
result = convert_math_to_mathjax(text)
self.assertEqual(result, text)
def test_cjk_dollar_spans_stripped(self) -> None:
result = convert_math_to_mathjax("price $100美元$ done")
self.assertEqual(result, "price 100美元 done")
def test_no_math_passthrough(self) -> None:
text = "No math here at all."
self.assertEqual(convert_math_to_mathjax(text), text)
def test_mixed_display_and_inline(self) -> None:
text = "Inline $a$ and display $$b$$."
result = convert_math_to_mathjax(text)
self.assertEqual(result, "Inline \\\\(a\\\\) and display \\\\[b\\\\].")
def test_asterisk_escaped_inside_math(self) -> None:
result = convert_math_to_mathjax("$$n*CHW$$")
self.assertEqual(result, "\\\\[n\\*CHW\\\\]")
def test_underscore_escaped_inside_math(self) -> None:
result = convert_math_to_mathjax("$x_i$")
self.assertEqual(result, "\\\\(x\\_i\\\\)")
if __name__ == "__main__":
unittest.main()