From fc2da556258fd3681726f095b2716bb7a46de565 Mon Sep 17 00:00:00 2001 From: AryanRG Date: Sat, 17 Oct 2026 10:57:24 +0530 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20Preserve=20literal=20alt?= =?UTF-8?q?=20text=20in=20HTML=20images?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++ myst_parser/mdit_to_docutils/html_to_nodes.py | 12 ++- tests/test_html/test_html_to_nodes.py | 86 ++++++++++++++++++- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff2d904f..4976627f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### 🐛 Bug Fixes + +- Preserve literal HTML image attribute values containing quotes, hash signs, and newlines when using the `html_image` extension. + ## 5.1.0 - 2026-05-13 ### ✨ New Features diff --git a/myst_parser/mdit_to_docutils/html_to_nodes.py b/myst_parser/mdit_to_docutils/html_to_nodes.py index d8d07b09..6ff14481 100644 --- a/myst_parser/mdit_to_docutils/html_to_nodes.py +++ b/myst_parser/mdit_to_docutils/html_to_nodes.py @@ -114,14 +114,18 @@ def _html_ast_to_nodes( " missing 'src' attribute", line=line_number ) ] - content = "\n".join( - f":{k}: {v}" + image_options = { + k: v.strip() if k in {"width", "height"} else v for k, v in sorted(child.attrs.items()) if k in OPTION_KEYS_IMAGE - ) + } nodes_list.extend( renderer.run_directive( - "image", child.attrs["src"], content, line_number + "image", + child.attrs["src"], + "", + line_number, + additional_options=image_options, ) ) diff --git a/tests/test_html/test_html_to_nodes.py b/tests/test_html/test_html_to_nodes.py index d54f0f1f..f8ec56c4 100644 --- a/tests/test_html/test_html_to_nodes.py +++ b/tests/test_html/test_html_to_nodes.py @@ -1,19 +1,32 @@ +from html import escape +from io import StringIO from pathlib import Path from unittest.mock import Mock import pytest from docutils import nodes +from docutils.core import publish_doctree from myst_parser.config.main import MdParserConfig from myst_parser.mdit_to_docutils.html_to_nodes import html_to_nodes +from myst_parser.parsers.docutils_ import Parser FIXTURE_PATH = Path(__file__).parent @pytest.fixture() def mock_renderer(): - def _run_directive(name: str, first_line: str, content: str, position: int): + def _run_directive( + name: str, + first_line: str, + content: str, + position: int, + additional_options: dict[str, str] | None = None, + ) -> list[nodes.Element]: node = nodes.Element(name=name, first=first_line, position=position) + if additional_options: + # Display options in the same format as the content-based fixtures. + content = "\n".join(f":{k}: {v}" for k, v in additional_options.items()) node += nodes.Text(content) return [node] @@ -33,3 +46,74 @@ def test_html_to_nodes(file_params, mock_renderer, normalize_doctree_xml): output = nodes.container() output += html_to_nodes(file_params.content, line_number=0, renderer=mock_renderer) file_params.assert_expected(normalize_doctree_xml(output.pformat()), rstrip=True) + + +@pytest.mark.parametrize( + "alt", + ["ordinary text", "A # sign", '"quoted" text', "First\nsecond", " padded text "], +) +@pytest.mark.parametrize("inline", [False, True]) +def test_html_image_literal_alt(alt: str, inline: bool) -> None: + """HTML attributes must not be interpreted as directive option syntax.""" + image = f'{escape(alt, quote=True)}' + source = f"before {image} after\n" if inline else f"{image}\n" + warnings = StringIO() + document = publish_doctree( + source, + parser=Parser(), + settings_overrides={ + "myst_enable_extensions": ["html_image"], + "warning_stream": warnings, + }, + ) + images = list(document.findall(nodes.image)) + assert len(images) == 1 + assert images[0]["alt"] == alt + assert warnings.getvalue() == "" + + +@pytest.mark.parametrize( + ("width", "height"), + [(" 200 ", " 100 "), ("\t200px\t", "\t100px\t"), (" 50% ", " 2em ")], +) +@pytest.mark.parametrize("inline", [False, True]) +def test_html_image_padded_dimensions(width: str, height: str, inline: bool) -> None: + """Dimension values retain the whitespace normalization of directive options.""" + image = f"" + source = f"before {image} after\n" if inline else f"{image}\n" + warnings = StringIO() + document = publish_doctree( + source, + parser=Parser(), + settings_overrides={ + "myst_enable_extensions": ["html_image"], + "warning_stream": warnings, + }, + ) + images = list(document.findall(nodes.image)) + assert len(images) == 1 + assert warnings.getvalue() == "" + assert images[0]["width"] == width.strip() + assert images[0]["height"] == height.strip() + + +@pytest.mark.parametrize("dimension", ["width", "height"]) +@pytest.mark.parametrize("inline", [False, True]) +def test_html_image_invalid_dimension(dimension: str, inline: bool) -> None: + """Normalizing whitespace must not bypass dimension validation.""" + image = f"" + source = f"before {image} after\n" if inline else f"{image}\n" + warnings = StringIO() + document = publish_doctree( + source, + parser=Parser(), + settings_overrides={ + "myst_enable_extensions": ["html_image"], + "warning_stream": warnings, + }, + ) + images = list(document.findall(nodes.image)) + assert len(images) == 1 + assert dimension not in images[0] + assert f"Invalid option value for '{dimension}'" in warnings.getvalue() + assert "[myst.directive_option]" in warnings.getvalue()