Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ The converter module parses Markdown using [`mistune`](https://github.com/leptur
| `- [x] task` | rendered checkbox |
| `text[^1]` | `#footnote[...]` |
| `![alt](local.png)` | `#image("local.png")` |
| `<img src="local.png" alt="alt">` | Embedded image, using the same path as Markdown images; sizing attributes are ignored |
| `> blockquote` | `#block(...)` |
| `---` | `#line(...)` |

Expand Down Expand Up @@ -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 `<img>` 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]`)
Expand Down Expand Up @@ -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)**

</div>
</div>
31 changes: 29 additions & 2 deletions doc_engine/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import re
from dataclasses import dataclass, field
from html.parser import HTMLParser
from pathlib import Path
from typing import Any

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

Expand Down Expand Up @@ -239,7 +250,22 @@ def inline_html(self, token: dict, state: Any) -> str:
raw = token.get("raw", "").strip().lower()
if raw in ("<br>", "<br/>", "<br />"):
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'])}$"
Expand Down Expand Up @@ -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 ""
Expand Down
81 changes: 81 additions & 0 deletions tests/test_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('<img src="logo.png">', 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 <img src="logo.png" /> 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(
'<div>\n<img src="one.png"><img src="two.png">\n</div>\n\n![again](one.png)',
base_dir=tmp_path,
)
assert len(result.assets) == 2
first, second = result.assets
assert result.body.index(first) < result.body.index(second)
assert result.body.count(first) == 2

def test_attribute_case_quotes_and_entities(self, tmp_path) -> None:
(tmp_path / "a&b.png").write_bytes(b"image")
result = convert_document("<IMG SRC='a&amp;b.png' ALT='logo' />", 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('<img src="missing.png" alt="A &amp; *B*">', 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(
'<img src="https://example.test/a.png" alt="remote">', 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(
'<img src="https://example.test/a.png">',
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(
'<!-- <img src="logo.png"> -->\n\n<script>"<img src=logo.png>"</script>',
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('<img alt="missing">')
assert convert("a<br>b") == "a\\\nb\n\n"


class TestImages:
def test_local_image_is_embedded(self, tmp_path) -> None:
(tmp_path / "logo.png").write_bytes(b"\x89PNG\r\n")
Expand Down
Loading