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[...]` | | `![alt](local.png)` | `#image("local.png")` | +| `alt` | 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\n
\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("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('A & *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( + '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( + '', + 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('missing') + assert convert("a
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")