feat(markdown): pure-Python port of imgui_md with overlap fix
ADD src/md_renderer_py.py: Full port of mekhontsev/imgui_md to pure Python.
- Uses markdown-it-py (already a transitive dep) for AST parsing.
- Walks the token tree, calling imgui primitives directly.
- Mirrors the C++ API surface: MarkdownOptions, MarkdownCallbacks,
MarkdownRenderer.render(), render_unindented().
- Code blocks delegated via set_external_code_block_handler callback.
- All other content (paragraphs, headings, lists, code, tables, hr,
emphasis, strong, links, blockquotes) rendered natively.
ROOT CAUSE OF BULLET OVERLAP (now fixed at the source):
imgui-md C++ BLOCK_P guards NewLine() behind 'if (!m_list_stack.empty())'
(imgui_md.cpp line ~145). Inside lists, paragraph transitions don't
advance the cursor Y. The Python port calls imgui.new_line() explicitly
between paragraphs in a list item, eliminating the overlap.
ROOT CAUSE OF '*' BULLET Y-OVERLAP (now fixed at the source):
imgui-md C++ BLOCK_LI for '*' delim calls ImGui::Bullet() without
ImGui::SameLine() (imgui_md.cpp line ~95). The Python port calls
imgui.bullet() + imgui.same_line() for all markers uniformly.
REMOVED in src/markdown_helper.py:
- _normalize_bullet_delimiters (no longer needed)
- _normalize_nested_list_endings (no longer needed)
- _normalize_list_continuations (no longer needed)
- parse_tables / render_table (renderer handles tables natively)
- All 'imgui_md' body rendering (replaced by Python port)
TESTS:
- tests/test_md_renderer_py.py (new): 16 unit tests for the Python port
covering paragraphs, headings, lists, nested lists, emphasis, strong,
code, links, tables, hr, unindented.
- tests/test_markdown_helper_bullets.py (rewritten): 13 tests for the
integration with the public MarkdownRenderer class.
- tests/test_markdown_render_robust.py (updated): 2 tests verifying
table content is routed through the new Python renderer (not imgui_md).
- tests/test_markdown_table.py / _render.py / _columns.py / _wrapped.py:
unchanged (test the standalone render_table which is still used by
the new renderer as a fallback for any unhandled cases).
42/42 markdown tests pass. 1-space indentation. 1 C++ dependency
removed (imgui_md is no longer used at runtime).
NOT FIXED (known limitations of the new renderer):
- Inline code rendering uses a tinted small_button (not monospace)
- Heading fonts use the default font (no separate bold/large fonts)
- Image rendering shows a placeholder text
- These can be improved by subclassing MarkdownRenderer
This commit is contained in:
@@ -1,17 +1,48 @@
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import patch, MagicMock
|
||||
from src.markdown_helper import MarkdownRenderer
|
||||
from src.markdown_table import parse_tables
|
||||
|
||||
def _mock_table_calls(mock_imgui):
|
||||
mock_imgui.TableFlags_ = type("T", (), {"borders": 1, "row_bg": 2, "resizable": 4})()
|
||||
mock_imgui.begin_table.return_value = True
|
||||
mock_imgui.table_next_column = lambda: None
|
||||
mock_imgui.table_next_row = lambda: None
|
||||
mock_imgui.table_headers_row = lambda: None
|
||||
mock_imgui.text = lambda *a, **k: None
|
||||
mock_imgui.end_table = lambda: None
|
||||
|
||||
def test_tables_in_crlf_text_all_get_masked():
|
||||
def _mock_imgui(mock_imgui):
|
||||
mock_imgui.TableFlags_ = type("T", (), {"borders": 1, "row_bg": 2, "resizable": 4})()
|
||||
mock_imgui.TableColumnFlags_ = type("C", (), {"width_stretch": 8})()
|
||||
mock_imgui.Col_ = type("C", (), {"text": 0, "text_disabled": 1, "frame_bg": 2,
|
||||
"button": 3, "button_hovered": 4, "button_active": 5})()
|
||||
mock_imgui.begin_table = MagicMock(return_value=True)
|
||||
mock_imgui.end_table = MagicMock()
|
||||
mock_imgui.table_setup_column = MagicMock()
|
||||
mock_imgui.table_next_row = MagicMock()
|
||||
mock_imgui.table_next_column = MagicMock()
|
||||
mock_imgui.text = MagicMock()
|
||||
mock_imgui.text_wrapped = MagicMock()
|
||||
mock_imgui.bullet = MagicMock()
|
||||
mock_imgui.same_line = MagicMock()
|
||||
mock_imgui.new_line = MagicMock()
|
||||
mock_imgui.indent = MagicMock()
|
||||
mock_imgui.unindent = MagicMock()
|
||||
mock_imgui.spacing = MagicMock()
|
||||
mock_imgui.separator = MagicMock()
|
||||
mock_imgui.push_style_color = MagicMock()
|
||||
mock_imgui.pop_style_color = MagicMock()
|
||||
mock_imgui.push_font = MagicMock()
|
||||
mock_imgui.pop_font = MagicMock()
|
||||
mock_imgui.small_button = MagicMock()
|
||||
mock_imgui.get_style = MagicMock(return_value=MagicMock(
|
||||
colors={i: MagicMock(x=1, y=1, z=1, w=1) for i in range(6)}
|
||||
))
|
||||
mock_imgui.get_io = MagicMock(return_value=MagicMock(fonts=MagicMock(fonts=[None])))
|
||||
mock_imgui.calc_text_size = MagicMock(return_value=MagicMock(x=50, y=20))
|
||||
mock_imgui.get_item_rect_min = MagicMock(return_value=MagicMock(x=0, y=0))
|
||||
mock_imgui.get_window_draw_list = MagicMock(return_value=MagicMock(
|
||||
add_line=MagicMock()
|
||||
))
|
||||
mock_imgui.is_item_hovered = MagicMock(return_value=False)
|
||||
mock_imgui.is_mouse_released = MagicMock(return_value=False)
|
||||
mock_imgui.get_text_line_height = MagicMock(return_value=20.0)
|
||||
mock_imgui.ImVec2 = lambda *a: MagicMock(x=a[0] if a else 0, y=a[1] if len(a) > 1 else 0)
|
||||
|
||||
|
||||
def test_tables_in_crlf_text_are_handled_by_python_renderer():
|
||||
text = (
|
||||
"# Title\r\n"
|
||||
"\r\n"
|
||||
@@ -27,14 +58,15 @@ def test_tables_in_crlf_text_all_get_masked():
|
||||
)
|
||||
blocks = parse_tables(text)
|
||||
assert len(blocks) == 2
|
||||
with patch("src.markdown_helper.imgui_md") as mock_md, patch("src.markdown_helper.imgui") as mock_imgui, patch("src.markdown_table.imgui") as mock_table_imgui:
|
||||
_mock_table_calls(mock_table_imgui)
|
||||
with patch("src.md_renderer_py.imgui") as mock_imgui, \
|
||||
patch("src.markdown_helper.imgui_md") as mock_md:
|
||||
_mock_imgui(mock_imgui)
|
||||
MarkdownRenderer().render(text, context_id="t")
|
||||
full = "".join(str(c) for c in mock_md.render.call_args_list)
|
||||
for needle in ["| A | B |", "|---|", "| 1 | 2 |", "| X | Y |", "| 3 | 4 |"]:
|
||||
assert needle not in full, f"Raw table text leaked to imgui_md: {needle!r} in calls: {full!r}"
|
||||
assert mock_md.render.call_count == 0, "imgui_md.render must NOT be called — the new design uses the pure-Python renderer"
|
||||
assert mock_imgui.begin_table.call_count >= 2, f"expected at least 2 begin_table calls (one per table), got {mock_imgui.begin_table.call_count}"
|
||||
assert mock_imgui.text_wrapped.called, "body text should still render via text_wrapped"
|
||||
|
||||
def test_duplicate_table_content_both_get_replaced():
|
||||
def test_duplicate_table_content_both_get_handled():
|
||||
text = (
|
||||
"| A | B |\r\n"
|
||||
"|---|---|\r\n"
|
||||
@@ -48,9 +80,9 @@ def test_duplicate_table_content_both_get_replaced():
|
||||
)
|
||||
blocks = parse_tables(text)
|
||||
assert len(blocks) == 2
|
||||
with patch("src.markdown_helper.imgui_md") as mock_md, patch("src.markdown_helper.imgui") as mock_imgui, patch("src.markdown_table.imgui") as mock_table_imgui:
|
||||
_mock_table_calls(mock_table_imgui)
|
||||
with patch("src.md_renderer_py.imgui") as mock_imgui, \
|
||||
patch("src.markdown_helper.imgui_md") as mock_md:
|
||||
_mock_imgui(mock_imgui)
|
||||
MarkdownRenderer().render(text, context_id="dup")
|
||||
full = "".join(str(c) for c in mock_md.render.call_args_list)
|
||||
assert "| A | B |" not in full, f"Raw table text leaked on duplicate: {full!r}"
|
||||
|
||||
assert mock_md.render.call_count == 0
|
||||
assert mock_imgui.begin_table.call_count >= 2, f"expected 2 begin_table calls, got {mock_imgui.begin_table.call_count}"
|
||||
|
||||
Reference in New Issue
Block a user