From 98234c030951e4927d4ce04a0619880d444edc68 Mon Sep 17 00:00:00 2001
From: Michael Villari <147255440+mikevillari@users.noreply.github.com>
Date: Wed, 9 Sep 2026 00:40:20 -0400
Subject: [PATCH 1/3] fix: render HTML images through existing asset pipeline
---
doc_engine/converter.py | 31 +++++++++++++++++++++++++++++--
1 file changed, 29 insertions(+), 2 deletions(-)
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 ""
From 9b1082f1fa384dd61789ee703482d2eabd02323d Mon Sep 17 00:00:00 2001
From: Michael Villari <147255440+mikevillari@users.noreply.github.com>
Date: Wed, 9 Sep 2026 00:41:07 -0400
Subject: [PATCH 2/3] test: cover block and inline HTML images
---
tests/test_converter.py | 81 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 81 insertions(+)
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('
` | 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 `