From 9eaef70e9afc11d35d6dd9f8bbb27823378029b4 Mon Sep 17 00:00:00 2001 From: Yuzhong Zhang Date: Tue, 1 Sep 2026 20:55:49 +0000 Subject: [PATCH 1/2] Fix wrapped {table} captions becoming body paragraphs MyST only takes the directive argument from the opening fence line, so a hard-wrapped table caption leaked into the body and RSTTable failed with "exactly one table expected". Fold leading non-table body lines into the caption when a table follows. Fixes #1104 --- myst_parser/parsers/directives.py | 62 +++++++++++++++++++ .../fixtures/sphinx_directives.md | 35 +++++++++++ tests/test_renderers/test_parse_directives.py | 45 ++++++++++++++ 3 files changed, 142 insertions(+) diff --git a/myst_parser/parsers/directives.py b/myst_parser/parsers/directives.py index 61c4d524..ac639e53 100644 --- a/myst_parser/parsers/directives.py +++ b/myst_parser/parsers/directives.py @@ -45,6 +45,7 @@ from docutils.parsers.rst import Directive from docutils.parsers.rst.directives import flag from docutils.parsers.rst.directives.misc import TestDirective +from docutils.parsers.rst.directives.tables import RSTTable from docutils.parsers.rst.states import MarkupError from myst_parser.warnings_ import MystWarnings @@ -161,6 +162,11 @@ def parse_directive_text( body_lines = body_lines[1:] content_offset += 1 + if issubclass(directive_class, RSTTable): + arguments, body_lines, content_offset = _fold_table_caption_continuation( + arguments, body_lines, content_offset + ) + # check for body content if body_lines and not directive_class.has_content: parse_warnings.append(ParseWarnings("Has content, but none permitted")) @@ -364,6 +370,62 @@ def _option_line(name: str) -> int | None: return _DirectiveOptions(content, new_options, validation_errors, has_options_block) +def _is_table_markup_line(line: str) -> bool: + """Return True if *line* looks like Markdown or rST table markup.""" + stripped = line.lstrip() + if not stripped: + return False + if stripped.startswith("|"): + return True + # rST grid table border: +---+---+ + if stripped.startswith("+") and set(stripped.rstrip()) <= {"+", "-", "=", " "}: + return True + # rST simple table header: ===== ===== + if stripped.startswith("=") and set(stripped.rstrip()) <= {"=", " "}: + return True + return False + + +def _fold_table_caption_continuation( + arguments: list[str], body_lines: list[str], content_offset: int +) -> tuple[list[str], list[str], int]: + """Join wrapped ``{table}`` caption lines into the directive argument. + + MyST takes directive arguments from the opening fence line only. A caption + that wraps onto the next line (hard-wrapped prose, or a manual break) + therefore becomes a leading paragraph in the body, and the table directive + fails with "exactly one table expected". + + Leading non-table body lines are treated as caption continuation only when + a table follows, so unrelated invalid content is left unchanged. + """ + caption_lines: list[str] = [] + index = 0 + while index < len(body_lines): + line = body_lines[index] + if not line.strip() or _is_table_markup_line(line): + break + caption_lines.append(line.strip()) + index += 1 + + if not caption_lines: + return arguments, body_lines, content_offset + + remaining = index + while remaining < len(body_lines) and not body_lines[remaining].strip(): + remaining += 1 + + if remaining >= len(body_lines) or not _is_table_markup_line(body_lines[remaining]): + return arguments, body_lines, content_offset + + caption_extra = " ".join(caption_lines) + if arguments: + arguments = [f"{arguments[0].rstrip()} {caption_extra}"] + else: + arguments = [caption_extra] + return arguments, body_lines[remaining:], content_offset + remaining + + def parse_directive_arguments( directive_cls: type[Directive], arg_text: str ) -> list[str]: diff --git a/tests/test_renderers/fixtures/sphinx_directives.md b/tests/test_renderers/fixtures/sphinx_directives.md index 99370f05..c864cfd5 100644 --- a/tests/test_renderers/fixtures/sphinx_directives.md +++ b/tests/test_renderers/fixtures/sphinx_directives.md @@ -261,6 +261,41 @@ table (`sphinx.directives.patches.RSTTable`): 2 . +table wrapped caption: +. +```{table} Mid-syllable marks that must be tagged for sorting with +above-base consonants + +| a | b | +|---|---| +| 1 | 2 | +``` +. + + + + Mid-syllable marks that must be tagged for sorting with above-base consonants + <tgroup cols="2"> + <colspec colwidth="50"> + <colspec colwidth="50"> + <thead> + <row> + <entry> + <paragraph> + a + <entry> + <paragraph> + b + <tbody> + <row> + <entry> + <paragraph> + 1 + <entry> + <paragraph> + 2 +. + csv-table (`sphinx.directives.patches.CSVTable`): . ```{csv-table} diff --git a/tests/test_renderers/test_parse_directives.py b/tests/test_renderers/test_parse_directives.py index 79b1bc9f..d02d2dc0 100644 --- a/tests/test_renderers/test_parse_directives.py +++ b/tests/test_renderers/test_parse_directives.py @@ -5,6 +5,7 @@ import yaml from docutils.parsers.rst.directives.admonitions import Admonition, Note from docutils.parsers.rst.directives.body import Rubric +from docutils.parsers.rst.directives.tables import RSTTable from markdown_it import MarkdownIt from sphinx.directives.code import CodeBlock @@ -246,3 +247,47 @@ def test_options_to_tokens_comment_lines(): _, state = options_to_tokens("# first\na: 1 # second\n# third\nb: 2\n") assert state.has_comments assert state.comment_lines == [0, 1, 2] + + +TABLE_BODY = "| a | b |\n|---|---|\n| 1 | 2 |" + + +@pytest.mark.parametrize( + "first_line,content,caption", + [ + ( + "Mid-syllable marks that must be tagged for sorting with", + "above-base consonants\n\n" + TABLE_BODY, + "Mid-syllable marks that must be tagged for sorting with above-base consonants", + ), + ( + "Mid-syllable marks that must be tagged for sorting with", + " above-base consonants\n\n" + TABLE_BODY, + "Mid-syllable marks that must be tagged for sorting with above-base consonants", + ), + ], +) +def test_table_wrapped_caption_folded_into_argument(first_line, content, caption): + """A hard-wrapped ``{table}`` caption must not leak into the body. + + MyST takes directive arguments from the opening fence line only, so a + caption that wraps (the usual ~70-column prose wrap) becomes a leading + paragraph in the body and the table directive fails with + "exactly one table expected". + """ + result = parse_directive_text(RSTTable, first_line, content) + assert result.arguments == [caption] + assert result.body == TABLE_BODY.splitlines() + assert not result.warnings + + +def test_table_caption_on_one_line_unchanged(): + """A single-line caption is still the only argument, body is the table.""" + result = parse_directive_text( + RSTTable, + "Table caption", + "\n" + TABLE_BODY, + ) + assert result.arguments == ["Table caption"] + assert result.body == TABLE_BODY.splitlines() + assert result.body_offset == 1 From de3ad6a19eeaa50cb9eb3a6059878b2498327d29 Mon Sep 17 00:00:00 2001 From: Yuzhong Zhang <BetterAndBetterII@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:23:03 +0000 Subject: [PATCH 2/2] Return table-separator condition directly for ruff SIM103 --- myst_parser/parsers/directives.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/myst_parser/parsers/directives.py b/myst_parser/parsers/directives.py index ac639e53..0fb169b2 100644 --- a/myst_parser/parsers/directives.py +++ b/myst_parser/parsers/directives.py @@ -381,9 +381,7 @@ def _is_table_markup_line(line: str) -> bool: if stripped.startswith("+") and set(stripped.rstrip()) <= {"+", "-", "=", " "}: return True # rST simple table header: ===== ===== - if stripped.startswith("=") and set(stripped.rstrip()) <= {"=", " "}: - return True - return False + return stripped.startswith("=") and set(stripped.rstrip()) <= {"=", " "} def _fold_table_caption_continuation(