diff --git a/CHANGELOG.md b/CHANGELOG.md index 85df484..6ec7d6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # Changelog + +## Unreleased + +### Fixed + +- Preserve absolute and home-relative local Markdown image paths during upload, + retain existing root-relative asset fallback, and report missing files or + failed uploads instead of saving broken image sources. ## 0.8.0 diff --git a/docs/markdown.md b/docs/markdown.md index f48f8f0..a6d342a 100644 --- a/docs/markdown.md +++ b/docs/markdown.md @@ -105,7 +105,15 @@ A paragraph containing only an image becomes a captioned image. - **Link:** wrap the image in a link — `[![alt](url)](https://target.com)`. - **Local upload:** if `api=` is passed and the `src` is a local path (not an `http(s)` URL), the file is uploaded to Substack and the returned URL is used. - Local paths resolve relative to the current working directory. + Absolute paths are preserved, `~` expands to the user's home directory, and + relative paths resolve from the current working directory. A leading `/` is + removed only as a legacy fallback when the absolute file does not exist and + the corresponding relative file does. Missing files and failed uploads raise + an error naming the file instead of silently saving a broken image. + PNG, JPEG, GIF, and WebP are uploaded without conversion, including animations. + HTTP(S) and protocol-relative URLs remain unchanged. Without `api=`, image + sources remain unchanged and no files are uploaded or checked for existence. + Rendering with `api=` can upload images even during a draft update's dry run. ```markdown ![A chart](chart.png "Figure 1: quarterly results") diff --git a/substack/mdrender.py b/substack/mdrender.py index 59f373f..de494c9 100644 --- a/substack/mdrender.py +++ b/substack/mdrender.py @@ -21,7 +21,9 @@ import copy import json import re +from pathlib import Path from typing import Dict, List, Optional +from urllib.parse import unquote, urlsplit from markdown_it import MarkdownIt from markdown_it.tree import SyntaxTreeNode @@ -46,19 +48,21 @@ def parse_node_marker(comment_content: str) -> dict | None: """ Parse a python-substack-node:v1 comment marker and return the parsed JSON dictionary. - + If it is not a python-substack-node:v1 marker, returns None. If it is an attempted marker but is corrupt/malformed, raises ValueError. """ clean = comment_content.strip() - match = re.match(r"^$", clean) + match = re.match( + r"^$", clean + ) if not match: if "python-substack-node:v1" in clean: raise ValueError("Corrupt marker format") return None - + encoded = match.group(1) - + try: padding = len(encoded) % 4 if padding: @@ -173,13 +177,28 @@ def _only_image(inline: SyntaxTreeNode) -> Optional[SyntaxTreeNode]: def _captioned_image(img: SyntaxTreeNode, api) -> Dict: src = img.attrs.get("src", "") - if src.startswith("/"): - src = src[1:] - if api is not None and not src.startswith("http"): + if api is not None and ( + urlsplit(src).scheme.lower() not in ("http", "https") + and not src.startswith("//") + ): + # Markdown destinations encode spaces and non-ASCII characters as URLs. + path = Path(unquote(src)).expanduser() + if not path.is_file() and src.startswith("/"): + # Preserve legacy root-relative asset paths only when they resolve + # to a real file; an existing absolute path always takes precedence. + relative = Path(unquote(src[1:])) + if relative.is_file(): + path = relative + if not path.is_file(): + raise FileNotFoundError(f"Local image file not found: {path}") try: - src = api.get_image(src).get("url") - except Exception: - pass + uploaded = api.get_image(str(path)) + url = uploaded.get("url") + if not isinstance(url, str) or not url.startswith(("https://", "http://")): + raise ValueError("Upload did not return an HTTP image URL") + except Exception as exc: + raise ValueError(f"Failed to upload local image: {path}") from exc + src = url # markdown-it stores the image alt text as the node's content, not in attrs. alt = img.content or img.attrs.get("alt") or None # Standard markdown image title `![alt](src "caption")` maps to Substack's caption node. diff --git a/tests/substack/test_local_markdown_images.py b/tests/substack/test_local_markdown_images.py new file mode 100644 index 0000000..1fb7859 --- /dev/null +++ b/tests/substack/test_local_markdown_images.py @@ -0,0 +1,126 @@ +"""Offline regression tests for Markdown local-image uploads.""" + +import os +from unittest.mock import Mock + +import pytest + +from substack.api import Api +from substack.post import Post + +UPLOADED = "https://substack-post-media.s3.amazonaws.com/test-image.gif" + + +@pytest.fixture +def api(): + client = Api.__new__(Api) + client.get_image = Mock(return_value={"url": UPLOADED}) + return client + + +def render(src, api=None): + post = Post(title="Image test", subtitle="", user_id=1) + post.from_markdown(f'![alt](<{src}> "caption")', api=api) + return post.draft_body["content"][0]["content"][0]["attrs"]["src"] + + +@pytest.mark.parametrize("extension", ["png", "jpg", "gif", "webp"]) +def test_absolute_file_uploaded_unchanged(tmp_path, api, extension): + path = tmp_path / f"image.{extension}" + path.write_bytes(b"image bytes") + assert render(str(path), api) == UPLOADED + api.get_image.assert_called_once_with(str(path)) + + +def test_home_path_expanded(tmp_path, monkeypatch, api): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + path = tmp_path / "image.png" + path.touch() + assert render("~/image.png", api) == UPLOADED + api.get_image.assert_called_once_with(str(path)) + + +def test_spaces_unicode_and_percent_in_filename(tmp_path, api): + path = tmp_path / "obraz żółty 100%.png" + path.touch() + assert render(str(path), api) == UPLOADED + api.get_image.assert_called_once_with(str(path)) + + +@pytest.mark.parametrize("src", ["missing.png", "/missing/image.gif", "~/missing.webp"]) +def test_missing_file_fails_before_upload(tmp_path, monkeypatch, api, src): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + with pytest.raises(FileNotFoundError, match="Local image file not found:") as error: + render(src, api) + assert src.rsplit("/", 1)[-1] in str(error.value) + api.get_image.assert_not_called() + + +def test_directory_is_not_an_image(tmp_path, api): + with pytest.raises(FileNotFoundError): + render(str(tmp_path), api) + api.get_image.assert_not_called() + + +@pytest.mark.parametrize("src", ["image.png", "/image.png"]) +def test_relative_and_legacy_root_relative_file(tmp_path, monkeypatch, api, src): + monkeypatch.chdir(tmp_path) + (tmp_path / "image.png").touch() + assert render(src, api) == UPLOADED + api.get_image.assert_called_once_with("image.png") + + +@pytest.mark.skipif( + os.name == "nt", + reason="Drive-qualified paths do not have a legacy root-relative fallback", +) +def test_absolute_file_takes_priority_over_relative(tmp_path, monkeypatch, api): + absolute = tmp_path / "image.png" + absolute.touch() + cwd = tmp_path / "cwd" + shadow = cwd / str(absolute)[1:] + shadow.parent.mkdir(parents=True) + shadow.touch() + monkeypatch.chdir(cwd) + assert render(str(absolute), api) == UPLOADED + api.get_image.assert_called_once_with(str(absolute)) + + +def test_upload_error_names_file(tmp_path, api): + path = tmp_path / "failed.gif" + path.touch() + api.get_image.side_effect = RuntimeError("upload refused") + with pytest.raises(ValueError, match="Failed to upload local image:") as error: + render(str(path), api) + assert str(path) in str(error.value) + assert error.value.__cause__ is api.get_image.side_effect + + +@pytest.mark.parametrize( + "result", [{}, {"url": None}, {"url": ""}, {"url": "local.png"}, None] +) +def test_invalid_upload_result_fails(tmp_path, api, result): + path = tmp_path / "failed.png" + path.touch() + api.get_image.return_value = result + with pytest.raises(ValueError, match="failed.png"): + render(str(path), api) + + +@pytest.mark.parametrize( + "src", + ["http://example.com/a.jpg", "https://example.com/a.png", "//example.com/a.gif"], +) +def test_remote_url_unchanged(api, src): + assert render(src, api) == src + api.get_image.assert_not_called() + + +@pytest.mark.parametrize( + "src", ["/missing/image.png", "~/missing.gif", "relative.webp"] +) +def test_without_api_retains_source(src): + assert render(src) == src