From 8ef468c2d0f19cc4ae84b2b893cb009f019f77e1 Mon Sep 17 00:00:00 2001 From: Leonardo Salas Date: Wed, 9 Sep 2026 19:55:59 -0600 Subject: [PATCH] feat: size HTML images from their width and height attributes Documents that embed pictures with `` usually say how big they want them, and this README is one of them: its GIFs carry a width and its sponsor badges a height. Ignoring both meant every one of them came out at the full text width. A CSS pixel is a 96th of an inch, so an attribute converts to a real length instead of a guess about how wide the page is. The fit still applies on top, so a number larger than the text block shrinks to it rather than running off the page, and a percentage is ignored because fitting the text block is what the default already does. --- README.md | 6 ++++-- doc_engine/compiler.py | 21 ++++++++++++------ doc_engine/converter.py | 47 ++++++++++++++++++++++++++++++++++------- tests/test_compiler.py | 19 +++++++++++++++++ tests/test_converter.py | 18 ++++++++++++++++ 5 files changed, 95 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 40ed6e6..6e4cbab 100644 --- a/README.md +++ b/README.md @@ -608,12 +608,14 @@ 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 | +| `` | Embedded image, using the same path as Markdown images | | `> blockquote` | `#block(...)` | | `---` | `#line(...)` | Special characters (`#`, `$`, `@`, `*`, `_`, etc.) are automatically escaped to prevent Typst interpretation. +A `width` or `height` on an HTML image is read as CSS pixels, a 96th of an inch each, and no picture is drawn wider than the text block however large the number is. Percentages are ignored, since fitting the text block is already the default. + ### PDF Templates Each template lives in `doc_engine/templates/` and exposes the same `setup_doc` entry point, so the compiler can swap between them with `--template`. The default `academic` template provides: @@ -744,7 +746,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] Raw HTML `` tags (inline or block), sized by `width` and `height` in pixels - [x] Math blocks (LaTeX `$…$` and `$$…$$`) - [x] Mermaid and SVG diagram blocks - [x] GitHub alerts (`> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]`) diff --git a/doc_engine/compiler.py b/doc_engine/compiler.py index e3dfb2b..b21ed90 100644 --- a/doc_engine/compiler.py +++ b/doc_engine/compiler.py @@ -139,12 +139,21 @@ def compile_pdf( typst.compile(str(main_file), output=resolved_output) -# Pictures keep their natural size unless they do not fit the text block, in -# which case they shrink until they do. Forcing every image to full width blows -# up small diagrams, and constraining only the width lets a tall one run past -# the bottom of the page, where Typst clips whatever does not fit. -_FIT_IMAGE = """#let fit-image(path) = context layout(area => { - let img = image(path) +# Pictures keep their natural size, or the size the document asked for, unless +# that does not fit the text block, in which case they shrink until it does. +# Forcing every image to full width blows up small diagrams, and constraining +# only the width lets a tall one run past the bottom of the page, where Typst +# clips whatever does not fit. +_FIT_IMAGE = """#let fit-image(path, width: none, height: none) = context layout(area => { + let img = if width != none and height != none { + image(path, width: width, height: height, fit: "contain") + } else if width != none { + image(path, width: width) + } else if height != none { + image(path, height: height) + } else { + image(path) + } let natural = measure(img) let scale = calc.min(1.0, area.width / natural.width, area.height / natural.height) if scale >= 1.0 { img } else { image(path, width: natural.width * scale) } diff --git a/doc_engine/converter.py b/doc_engine/converter.py index 09468f0..8480825 100644 --- a/doc_engine/converter.py +++ b/doc_engine/converter.py @@ -95,6 +95,33 @@ def parse_inline(inline: Any, match: Any, state: Any) -> int: md.inline.register("inline_math", _INLINE_MATH, parse_inline, before="link") +def _css_length(value: str | None) -> str | None: + """A Typst length for an HTML width or height written in pixels. + + A CSS pixel is a 96th of an inch, so a stated size converts to a real length + rather than to some guess about how wide a page is. Percentages are left + alone: they measure against a viewport a PDF does not have, and fitting the + picture to the text width is already what the default does. + """ + if not value: + return None + try: + pixels = float(value.strip().removesuffix("px").strip()) + except ValueError: + return None + if pixels <= 0: + return None + return f"{pixels * 0.75:g}pt" + + +def _size_arguments(attrs: dict) -> str: + return "".join( + f", {name}: {length}" + for name in ("width", "height") + if (length := _css_length(attrs.get(name))) + ) + + def _render_children(renderer: mistune.BaseRenderer, token: dict, state: Any) -> str: children = token.get("children") if not children: @@ -196,21 +223,21 @@ def _anchor_for(self, url: str) -> str | None: return self._anchors.get(resolved) def image(self, token: dict, state: Any) -> str: - url = token.get("attrs", {}).get("url", "") + attrs = token.get("attrs", {}) alt = _render_children(self, token, state) - asset = self._register_image(url) + asset = self._register_image(attrs.get("url", "")) if asset is None: return f"[{alt}]" if alt else "" - return self._place(asset) + return self._place(asset, _size_arguments(attrs)) - def _place(self, asset: str) -> str: + def _place(self, asset: str, size: str = "") -> str: """Emit a picture, cut across pages when it is too tall for one.""" if self._split_tall is None: - return f'#fit-image("{asset}")' + return f'#fit-image("{asset}"{size})' pieces = self._cut(asset) if len(pieces) == 1: - return f'#fit-image("{pieces[0]}")' - return "\n#pagebreak(weak: true)\n".join(f'#fit-image("{p}")' for p in pieces) + return f'#fit-image("{pieces[0]}"{size})' + return "\n#pagebreak(weak: true)\n".join(f'#fit-image("{p}"{size})' for p in pieces) def _cut(self, asset: str) -> list[str]: # The same picture can appear more than once, and it is registered under @@ -261,7 +288,11 @@ def _html_images(self, token: dict, state: Any) -> str: return "\n".join( self.image( { - "attrs": {"url": attrs.get("src") or ""}, + "attrs": { + "url": attrs.get("src") or "", + "width": attrs.get("width"), + "height": attrs.get("height"), + }, "children": [{"type": "text", "raw": attrs.get("alt") or ""}], }, state, diff --git a/tests/test_compiler.py b/tests/test_compiler.py index e0bb1e0..fdf93ac 100644 --- a/tests/test_compiler.py +++ b/tests/test_compiler.py @@ -1,3 +1,5 @@ +from PIL import Image + from doc_engine.compiler import compile_pdf @@ -19,3 +21,20 @@ def test_formatted_title_keeps_custom_template_contract(tmp_path): title_markup="*Bold* Title", ) assert output.read_bytes().startswith(b"%PDF-") + + +def test_a_requested_image_size_compiles(tmp_path): + assets = tmp_path / "assets" + assets.mkdir() + Image.new("RGB", (600, 300), "blue").save(assets / "box.png") + output = tmp_path / "sized.pdf" + compile_pdf( + '#fit-image("assets/box.png", width: 150pt)\n\n' + '#fit-image("assets/box.png", height: 25.5pt)\n\n' + '#fit-image("assets/box.png", width: 1500pt)\n', + "Sizes", + "Author", + str(output), + assets={"assets/box.png": str(assets / "box.png")}, + ) + assert output.read_bytes().startswith(b"%PDF-") diff --git a/tests/test_converter.py b/tests/test_converter.py index 3ed3607..0470e1a 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -230,6 +230,24 @@ def test_comments_and_script_content_are_not_images(self, tmp_path) -> None: assert not result.assets assert "#fit-image" not in result.body + def test_pixel_sizes_become_lengths(self, tmp_path) -> None: + (tmp_path / "logo.png").write_bytes(b"image") + body = convert_document('', base_dir=tmp_path).body + assert ", width: 615pt)" in body + body = convert_document('', base_dir=tmp_path).body + assert ", height: 25.5pt)" in body + + def test_unusable_sizes_fall_back_to_fitting(self, tmp_path) -> None: + (tmp_path / "logo.png").write_bytes(b"image") + for attribute in ('width="50%"', 'width="auto"', 'width="0"', 'width="-4"', 'width=""'): + body = convert_document(f'', base_dir=tmp_path).body + assert "width:" not in body + + def test_markdown_images_ask_for_no_size(self, tmp_path) -> None: + (tmp_path / "logo.png").write_bytes(b"image") + body = convert_document("![alt](logo.png)", base_dir=tmp_path).body + assert body.strip() == '#fit-image("assets/0_logo.png")' + def test_missing_src_and_existing_linebreak_behavior(self) -> None: assert "[missing]" in convert('missing') assert convert("a
b") == "a\\\nb\n\n"