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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
10 changes: 9 additions & 1 deletion docs/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
39 changes: 29 additions & 10 deletions substack/mdrender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean)
match = re.match(
r"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", 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:
Expand Down Expand Up @@ -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.
Expand Down
126 changes: 126 additions & 0 deletions tests/substack/test_local_markdown_images.py
Original file line number Diff line number Diff line change
@@ -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
Loading