Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions myst_parser/parsers/directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -364,6 +370,60 @@ 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: ===== =====
return stripped.startswith("=") and set(stripped.rstrip()) <= {"=", " "}


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]:
Expand Down
35 changes: 35 additions & 0 deletions tests/test_renderers/fixtures/sphinx_directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
```
.
<document source="<src>/index.md">
<table classes="colwidths-auto">
<title>
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}
Expand Down
45 changes: 45 additions & 0 deletions tests/test_renderers/test_parse_directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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