feat: improve default and local t2i rendering (#9803)

* feat: redesign default t2i template

* feat: improve local t2i rendering and migrate defaults

* fix: install CJK fonts in Docker image

* fix: preserve soft breaks and fit display math
This commit is contained in:
Soulter
2026-08-26 10:06:50 +08:00
committed by GitHub
parent 4d877c9919
commit 07013445f9
6 changed files with 2146 additions and 1071 deletions
+83
View File
@@ -0,0 +1,83 @@
import pytest
from astrbot.core.utils.t2i.local_strategy import (
CodeBlock,
FontManager,
HeadingBlock,
MarkdownParser,
MarkdownRenderer,
MathBlock,
TableBlock,
TextMeasurer,
)
def test_text_measurer_uses_content_and_wraps_to_requested_width() -> None:
"""Verify measurement uses real text and wrapping never exceeds the limit."""
font = FontManager.get_font(24)
assert (
TextMeasurer.get_text_size("WWWW", font)[0]
> TextMeasurer.get_text_size("iiii", font)[0]
)
max_width = 180
lines = TextMeasurer.split_text_to_fit_width(
"这是一段需要自动换行的中文 mixed-with-a-very-long-English-token 内容。",
font,
max_width,
)
assert len(lines) > 1
assert all(TextMeasurer.get_text_size(line, font)[0] <= max_width for line in lines)
@pytest.mark.asyncio
async def test_markdown_parser_recognizes_common_rich_blocks() -> None:
"""Verify headings, tables, code, and display math receive native blocks."""
markdown = """# Heading
| Name | Value |
| :--- | ---: |
| Wrap | A long table value |
```python
print("hello")
```
$$
E = mc^2
$$
"""
blocks = await MarkdownParser.parse(markdown)
assert any(isinstance(block, HeadingBlock) for block in blocks)
assert any(isinstance(block, TableBlock) for block in blocks)
assert any(isinstance(block, CodeBlock) for block in blocks)
assert any(isinstance(block, MathBlock) for block in blocks)
@pytest.mark.asyncio
async def test_markdown_renderer_produces_requested_width_with_wrapped_content() -> (
None
):
"""Verify a narrow render completes without allowing long content to expand it."""
renderer = MarkdownRenderer(font_size=22, width=420)
markdown = """## 自动换行
正文包含 **粗体**、`inline_code()` 和一个不会自然断开的超长字符串:abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz。
```python
result = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz"
```
| 项目 | 说明 |
| --- | --- |
| 表格 | 这一格也需要在固定宽度里自动换行 |
"""
image = await renderer.render(markdown)
assert image.width == 420
assert image.height > 400
+94
View File
@@ -0,0 +1,94 @@
import hashlib
import re
from pathlib import Path
import pytest
from astrbot.core.utils.t2i import template_manager
LEGACY_TEMPLATE = "<html>\n<body>legacy default</body>\n</html>\n"
CURRENT_TEMPLATE = "<html>\n<body>current default</body>\n</html>\n"
CUSTOM_TEMPLATE = "<html>\n<body>customized by user</body>\n</html>\n"
def test_default_template_preserves_soft_breaks_and_fits_display_math() -> None:
"""Verify the default template keeps Markdown and wide-math layout safe."""
template_path = (
Path(__file__).parents[1]
/ "astrbot/core/utils/t2i/template/base.html"
)
template = template_path.read_text(encoding="utf-8")
paragraph_rule = re.search(r"\n p \{(?P<body>.*?)\n \}", template, re.DOTALL)
math_rule = re.search(
r"\n \.katex-display \{(?P<body>.*?)\n \}",
template,
re.DOTALL,
)
assert paragraph_rule is not None
assert "white-space: normal;" in paragraph_rule.group("body")
assert math_rule is not None
assert "overflow-x: auto;" in math_rule.group("body")
assert 'querySelectorAll(".katex-display")' in template
assert "renderedWidth > availableWidth" in template
@pytest.mark.parametrize(
("user_content", "expected_content"),
[
pytest.param(None, CURRENT_TEMPLATE, id="missing-template"),
pytest.param(LEGACY_TEMPLATE, CURRENT_TEMPLATE, id="legacy-template"),
pytest.param(
LEGACY_TEMPLATE.replace("\n", "\r\n"),
CURRENT_TEMPLATE,
id="legacy-template-crlf",
),
pytest.param(CUSTOM_TEMPLATE, CUSTOM_TEMPLATE, id="custom-template"),
],
)
def test_initialize_user_templates_migrates_only_unmodified_defaults(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
user_content: str | None,
expected_content: str,
) -> None:
"""Verify automatic migration preserves customized user templates.
Args:
monkeypatch: Pytest fixture used to isolate AstrBot paths and legacy hashes.
tmp_path: Temporary directory used for built-in and user templates.
user_content: Existing user template content, or None when it is missing.
expected_content: Template content expected after manager initialization.
"""
builtin_root = tmp_path / "astrbot-root"
builtin_dir = builtin_root / "astrbot/core/utils/t2i/template"
builtin_dir.mkdir(parents=True)
(builtin_dir / "base.html").write_text(CURRENT_TEMPLATE, encoding="utf-8")
data_root = tmp_path / "data"
user_dir = data_root / "t2i_templates"
if user_content is not None:
user_dir.mkdir(parents=True)
(user_dir / "base.html").write_text(user_content, encoding="utf-8")
legacy_hash = hashlib.sha256(LEGACY_TEMPLATE.encode()).hexdigest()
monkeypatch.setattr(
template_manager,
"_LEGACY_CORE_TEMPLATE_HASHES",
{"base.html": frozenset({legacy_hash})},
)
monkeypatch.setattr(
template_manager,
"get_astrbot_path",
lambda: str(builtin_root),
)
monkeypatch.setattr(
template_manager,
"get_astrbot_data_path",
lambda: str(data_root),
)
template_manager.TemplateManager()
assert (user_dir / "base.html").read_text(encoding="utf-8") == expected_content