Repository: executablebooks/MyST-Parser
Pull request #620 (2022-09-27 09:04:22)

✨ NEW: Add `attrs_image` (experimental) extension
Use inline attributes to configure MD images:
`![alt](img/fun-fish.png){#id .class width="100px" align=center}`

codecov[bot]: # [Codecov](https://codecov.io/gh/executablebooks/MyST-Parser/pull/620?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks) Report
Base: **89.74**% // Head: **89.86**% // Increases project coverage by **`+0.11%`** :tada:
> Coverage data is based on head [(`714fa36`)](https://codecov.io/gh/executablebooks/MyST-Parser/pull/620?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks) compared to base [(`4a30c24`)](https://codecov.io/gh/executablebooks/MyST-Parser/commit/4a30c24d1043a26178df4e7a7e6aac6fed8f5b70?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks).
> Patch coverage: 100.00% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #620      +/-   ##
==========================================
+ Coverage   89.74%   89.86%   +0.11%     
==========================================
  Files          21       21              
  Lines        2125     2150      +25     
==========================================
+ Hits         1907     1932      +25     
  Misses        218      218              
```

| Flag | Coverage Δ | |
|---|---|---|
| pytests | `89.86% <100.00%> (+0.11%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/executablebooks/MyST-Parser/pull/620?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks) | Coverage Δ | |
|---|---|---|
| [myst\_parser/config/main.py](https://codecov.io/gh/executablebooks/MyST-Parser/pull/620/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks#diff-bXlzdF9wYXJzZXIvY29uZmlnL21haW4ucHk=) | `86.91% <ø> (ø)` | |
| [myst\_parser/mdit\_to\_docutils/base.py](https://codecov.io/gh/executablebooks/MyST-Parser/pull/620/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks#diff-bXlzdF9wYXJzZXIvbWRpdF90b19kb2N1dGlscy9iYXNlLnB5) | `92.13% <100.00%> (+0.24%)` | :arrow_up: |
| [myst\_parser/parsers/mdit.py](https://codecov.io/gh/executablebooks/MyST-Parser/pull/620/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks#diff-bXlzdF9wYXJzZXIvcGFyc2Vycy9tZGl0LnB5) | `96.87% <100.00%> (+0.15%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/executablebooks/MyST-Parser/pull/620?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks).


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6fb2bd1..aee3233 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -20,7 +20,7 @@ repos:
     - id: trailing-whitespace
 
   - repo: https://github.com/asottile/pyupgrade
-    rev: v2.37.3
+    rev: v2.38.2
     hooks:
     - id: pyupgrade
       args: [--py37-plus]
@@ -31,7 +31,7 @@ repos:
     - id: isort
 
   - repo: https://github.com/psf/black
-    rev: 22.6.0
+    rev: 22.8.0
     hooks:
     - id: black
 
@@ -52,7 +52,7 @@ repos:
       additional_dependencies:
       - sphinx~=5.0
       - markdown-it-py>=1.0.0,<3.0.0
-      - mdit-py-plugins~=0.3.0
+      - mdit-py-plugins~=0.3.1
       files: >
         (?x)^(
             myst_parser/.*py|
diff --git a/docs/conf.py b/docs/conf.py
index e2d0c5d..199a32a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -90,6 +90,7 @@ myst_enable_extensions = [
     "strikethrough",
     "substitution",
     "tasklist",
+    "attrs_image",
 ]
 myst_number_code_blocks = ["typescript"]
 myst_heading_anchors = 2
diff --git a/docs/syntax/optional.md b/docs/syntax/optional.md
index 4045eb2..c5ee44e 100644
--- a/docs/syntax/optional.md
+++ b/docs/syntax/optional.md
@@ -786,6 +786,42 @@ HTML image can also be used inline!
 
 I'm an inline image: <img src="img/fun-fish.png" height="20px">
 
+### Inline attributes
+
+:::{warning}
+This extension is currently experimental, and may change in future versions.
+:::
+
+By adding `"attrs_image"` to `myst_enable_extensions` (in the sphinx `conf.py` [configuration file](https://www.sphinx-doc.org/en/master/usage/configuration.html)),
+you can enable parsing of inline attributes for images.
+
+For example, the following Markdown:
+
+```md
+![image attrs](img/fun-fish.png){#imgattr .bg-primary width="100px" align=center}
+
+{ref}`a reference to the image <imgattr>`
+```
+
+will be parsed as:
+
+![image attrs](img/fun-fish.png){#imgattr .bg-primary width="100px" align=center}
+
+{ref}`a reference to the image <imgattr>`
+
+Inside the curly braces, the following syntax is possible:
+
+- `.foo` specifies `foo` as a class.
+  Multiple classes may be given in this way; they will be combined.
+- `#foo` specifies `foo` as an identifier.
+  An element may have only one identifier;
+  if multiple identifiers are given, the last one is used.
+- `key="value"` or `key=value` specifies a key-value attribute.
+    Quotes are not needed when the value consists entirely of
+    ASCII alphanumeric characters or `_` or `:` or `-`.
+    Backslash escapes may be used inside quoted values.
+- `%` begins a comment, which ends with the next `%` or the end of the attribute (`}`).
+
 (syntax/figures)=
 
 ## Markdown Figures
diff --git a/myst_parser/config/main.py b/myst_parser/config/main.py
index c1f1c7f..a134ea7 100644
--- a/myst_parser/config/main.py
+++ b/myst_parser/config/main.py
@@ -31,6 +31,7 @@ def check_extensions(_, __, value):
     diff = set(value).difference(
         [
             "amsmath",
+            "attrs_image",
             "colon_fence",
             "deflist",
             "dollarmath",
diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py
index 1709f1b..cedd6c3 100644
--- a/myst_parser/mdit_to_docutils/base.py
+++ b/myst_parser/mdit_to_docutils/base.py
@@ -785,6 +785,51 @@ class DocutilsRenderer(RendererProtocol):
         title = token.attrGet("title")
         if title:
             img_node["title"] = token.attrGet("title")
+
+        # apply other attributes that can be set on the image
+        if "class" in token.attrs:
+            img_node["classes"].extend(str(token.attrs["class"]).split())
+        if "width" in token.attrs:
+            try:
+                width = directives.length_or_percentage_or_unitless(
+                    str(token.attrs["width"])
+                )
+            except ValueError:
+                self.create_warning(
+                    f"Invalid width value for image: {token.attrs['width']!r}",
+                    line=token_line(token, default=0),
+                    subtype="image",
+                    append_to=self.current_node,
+                )
+            else:
+                img_node["width"] = width
+        if "height" in token.attrs:
+            try:
+                height = directives.length_or_unitless(str(token.attrs["height"]))
+            except ValueError:
+                self.create_warning(
+                    f"Invalid height value for image: {token.attrs['height']!r}",
+                    line=token_line(token, default=0),
+                    subtype="image",
+                    append_to=self.current_node,
+                )
+            else:
+                img_node["height"] = height
+        if "align" in token.attrs:
+            if token.attrs["align"] not in ("left", "center", "right"):
+                self.create_warning(
+                    f"Invalid align value for image: {token.attrs['align']!r}",
+                    line=token_line(token, default=0),
+                    subtype="image",
+                    append_to=self.current_node,
+                )
+            else:
+                img_node["align"] = token.attrs["align"]
+        if "id" in token.attrs:
+            name = nodes.fully_normalize_name(str(token.attrs["id"]))
+            img_node["names"].append(name)
+            self.document.note_explicit_target(img_node, img_node)
+
         self.current_node.append(img_node)
 
     # ### render methods for plugin tokens
diff --git a/myst_parser/parsers/mdit.py b/myst_parser/parsers/mdit.py
index 249acd6..8476495 100644
--- a/myst_parser/parsers/mdit.py
+++ b/myst_parser/parsers/mdit.py
@@ -9,6 +9,7 @@ from markdown_it import MarkdownIt
 from markdown_it.renderer import RendererProtocol
 from mdit_py_plugins.amsmath import amsmath_plugin
 from mdit_py_plugins.anchors import anchors_plugin
+from mdit_py_plugins.attrs import attrs_plugin
 from mdit_py_plugins.colon_fence import colon_fence_plugin
 from mdit_py_plugins.deflist import deflist_plugin
 from mdit_py_plugins.dollarmath import dollarmath_plugin
@@ -100,6 +101,8 @@ def create_md_parser(
         md.use(tasklists_plugin)
     if "substitution" in config.enable_extensions:
         md.use(substitution_plugin, *config.sub_delimiters)
+    if "attrs_image" in config.enable_extensions:
+        md.use(attrs_plugin, after=("image",))
     if config.heading_anchors is not None:
         md.use(
             anchors_plugin,
diff --git a/pyproject.toml b/pyproject.toml
index d375a60..880a4fd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,7 +37,7 @@ dependencies = [
     "docutils>=0.15,<0.20",
     "jinja2", # required for substitutions, but let sphinx choose version
     "markdown-it-py>=1.0.0,<3.0.0",
-    "mdit-py-plugins~=0.3.0",
+    "mdit-py-plugins~=0.3.1",
     "pyyaml",
     "sphinx>=4,<6",
     "typing-extensions",
@@ -68,6 +68,7 @@ testing = [
     "pytest-regressions",
     "pytest-param-files~=0.3.4",
     "sphinx-pytest",
+    "sphinx<5.2",  # TODO 5.2 changes the attributes of desc/desc_signature nodes
 ]
 
 [project.scripts]


Test patch:
diff --git a/tests/test_renderers/fixtures/myst-config.txt b/tests/test_renderers/fixtures/myst-config.txt
index 020623c..668895a 100644
--- a/tests/test_renderers/fixtures/myst-config.txt
+++ b/tests/test_renderers/fixtures/myst-config.txt
@@ -49,6 +49,8 @@ www.example.com
         strike
         <raw format="html" xml:space="preserve">
             

+
+<string>:1: (WARNING/2) Strikethrough is currently only supported in HTML output [myst.strikethrough]
 .
 
 [gfm-disallowed-html] --myst-gfm-only="yes"
@@ -141,3 +143,34 @@ www.commonmark.org/he<lp
             www.commonmark.org/he
         <lp
 .
+
+[attrs_image] --myst-enable-extensions=attrs_image
+.
+![a](b){#id .a width="100%" align=center height=20px}{.b}
+.
+<document source="<string>">
+    <paragraph>
+        <image align="center" alt="a" classes="a b" height="20px" ids="id" names="id" uri="b" width="100%">
+.
+
+[attrs_image_warnings] --myst-enable-extensions=attrs_image
+.
+![a](b){width=1x height=2x align=other }
+.
+<document source="<string>">
+    <paragraph>
+        <system_message level="2" line="1" source="<string>" type="WARNING">
+            <paragraph>
+                Invalid width value for image: '1x' [myst.image]
+        <system_message level="2" line="1" source="<string>" type="WARNING">
+            <paragraph>
+                Invalid height value for image: '2x' [myst.image]
+        <system_message level="2" line="1" source="<string>" type="WARNING">
+            <paragraph>
+                Invalid align value for image: 'other' [myst.image]
+        <image alt="a" uri="b">
+
+<string>:1: (WARNING/2) Invalid width value for image: '1x' [myst.image]
+<string>:1: (WARNING/2) Invalid height value for image: '2x' [myst.image]
+<string>:1: (WARNING/2) Invalid align value for image: 'other' [myst.image]
+.
diff --git a/tests/test_renderers/test_myst_config.py b/tests/test_renderers/test_myst_config.py
index 0f58cd7..31e2444 100644
--- a/tests/test_renderers/test_myst_config.py
+++ b/tests/test_renderers/test_myst_config.py
@@ -31,4 +31,8 @@ def test_cmdline(file_params):
         parser=Parser(),
         settings_overrides=settings,
     )
-    file_params.assert_expected(doctree.pformat(), rstrip_lines=True)
+    output = doctree.pformat()
+    warnings = report_stream.getvalue()
+    if warnings:
+        output += "\n" + warnings
+    file_params.assert_expected(output, rstrip_lines=True)