diff --git a/README.md b/README.md
index 08aef03..40ed6e6 100644
--- a/README.md
+++ b/README.md
@@ -608,6 +608,7 @@ The converter module parses Markdown using [`mistune`](https://github.com/leptur
| `- [x] task` | rendered checkbox |
| `text[^1]` | `#footnote[...]` |
| `` | `#image("local.png")` |
+| `
` | Embedded image, using the same path as Markdown images; sizing attributes are ignored |
| `> blockquote` | `#block(...)` |
| `---` | `#line(...)` |
@@ -743,6 +744,7 @@ docker run --rm -v "$PWD:/workspace" ghcr.io/leonardosalasd/doc-engine-cli build
- [x] Task lists (`- [x]` / `- [ ]`)
- [x] Footnotes (`[^1]`)
- [x] Local images, and remote ones with `--fetch-images`
+- [x] Raw HTML `` tags (inline or block); `width`, `height`, and other sizing attributes are not honored
- [x] Math blocks (LaTeX `$…$` and `$$…$$`)
- [x] Mermaid and SVG diagram blocks
- [x] GitHub alerts (`> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]`)
@@ -820,4 +822,4 @@ This project is licensed under the [MIT License](LICENSE).
**Built with [Typst](https://typst.app/) · Parsed with [mistune](https://github.com/lepture/mistune) · Styled with [Rich](https://github.com/Textualize/rich)**
-
\ No newline at end of file
+
diff --git a/doc_engine/converter.py b/doc_engine/converter.py
index ba35611..2351b8a 100644
--- a/doc_engine/converter.py
+++ b/doc_engine/converter.py
@@ -12,6 +12,7 @@
import re
from dataclasses import dataclass, field
+from html.parser import HTMLParser
from pathlib import Path
from typing import Any
@@ -114,6 +115,16 @@ class Conversion:
warnings: list[str] = field(default_factory=list)
+class _ImageParser(HTMLParser):
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.images: list[dict[str, str | None]] = []
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ if tag == "img":
+ self.images.append(dict(attrs))
+
+
class TypstRenderer(mistune.BaseRenderer):
NAME = "typst"
@@ -239,7 +250,22 @@ def inline_html(self, token: dict, state: Any) -> str:
raw = token.get("raw", "").strip().lower()
if raw in ("
", "
", "
"):
return "\\\n"
- return ""
+ return self._html_images(token, state)
+
+ def _html_images(self, token: dict, state: Any) -> str:
+ parser = _ImageParser()
+ parser.feed(token.get("raw", ""))
+ parser.close()
+ return "\n".join(
+ self.image(
+ {
+ "attrs": {"url": attrs.get("src") or ""},
+ "children": [{"type": "text", "raw": attrs.get("alt") or ""}],
+ },
+ state,
+ )
+ for attrs in parser.images
+ )
def inline_math(self, token: dict, state: Any) -> str:
return f"${latex.to_typst(token['raw'])}$"
@@ -339,7 +365,8 @@ def thematic_break(self, token: dict, state: Any) -> str:
return '\n#line(length: 100%, stroke: 0.5pt + rgb("#d0d0d0"))\n\n'
def block_html(self, token: dict, state: Any) -> str:
- return ""
+ body = self._html_images(token, state)
+ return f"{body}\n\n" if body else ""
def footnotes(self, token: dict, state: Any) -> str:
return ""
diff --git a/tests/test_converter.py b/tests/test_converter.py
index 648d92e..222318b 100644
--- a/tests/test_converter.py
+++ b/tests/test_converter.py
@@ -136,6 +136,87 @@ def test_definition_block_is_dropped(self) -> None:
assert result.count("The evidence.") == 1
+class TestHtmlImages:
+ def test_block_image_is_embedded(self, tmp_path) -> None:
+ (tmp_path / "logo.png").write_bytes(b"image")
+ result = convert_document('
', base_dir=tmp_path)
+ assert len(result.assets) == 1
+ assert f'#fit-image("{next(iter(result.assets))}")' in result.body
+
+ def test_inline_image_preserves_surrounding_text(self, tmp_path) -> None:
+ (tmp_path / "logo.png").write_bytes(b"image")
+ result = convert_document('Before
after.', base_dir=tmp_path)
+ assert len(result.assets) == 1
+ assert result.body.startswith("Before #fit-image(")
+ assert " after." in result.body
+
+ def test_wrapped_images_share_markdown_assets(self, tmp_path) -> None:
+ for name in ("one.png", "two.png"):
+ (tmp_path / name).write_bytes(b"image")
+ result = convert_document(
+ '

\n
", base_dir=tmp_path)
+ assert len(result.assets) == 1
+ assert next(iter(result.assets.values())).endswith("a&b.png")
+
+ def test_missing_image_uses_escaped_alt(self, tmp_path) -> None:
+ result = convert_document('
', base_dir=tmp_path)
+ assert not result.assets
+ assert r"[A & \*B\*]" in result.body
+
+ def test_remote_images_require_opt_in(self, tmp_path, monkeypatch) -> None:
+ def unexpected_fetch(*args):
+ raise AssertionError("remote fetch was not enabled")
+
+ monkeypatch.setattr("doc_engine.converter.remote.fetch", unexpected_fetch)
+ result = convert_document(
+ '
', base_dir=tmp_path
+ )
+ assert not result.assets
+ assert "[remote]" in result.body
+
+ def test_remote_image_reuses_enabled_fetch(self, tmp_path, monkeypatch) -> None:
+ image = tmp_path / "download.png"
+ image.write_bytes(b"image")
+ calls = []
+
+ def fetch(url, destination):
+ calls.append((url, destination))
+ return image
+
+ monkeypatch.setattr("doc_engine.converter.remote.fetch", fetch)
+ result = convert_document(
+ '
',
+ base_dir=tmp_path,
+ work_dir=tmp_path,
+ fetch_remote=True,
+ )
+ assert len(result.assets) == 1
+ assert calls == [("https://example.test/a.png", tmp_path)]
+
+ def test_comments_and_script_content_are_not_images(self, tmp_path) -> None:
+ (tmp_path / "logo.png").write_bytes(b"image")
+ result = convert_document(
+ '\n\n',
+ base_dir=tmp_path,
+ )
+ assert not result.assets
+ assert "#fit-image" not in result.body
+
+ def test_missing_src_and_existing_linebreak_behavior(self) -> None:
+ assert "[missing]" in convert('