Skip to content

Commit eb55543

Browse files
MattFisherclaude
andcommitted
Support image captions and complete the image2 attr schema
`![alt](url "caption")` now renders a `caption` sibling node inside `captionedImage`, matching what the Substack editor produces. `nodes.captioned_image()` gains the full set of `image2` attrs that Substack stores and returns (`srcNoWatermark`, `topImage`, `isProcessing`, `align`, `offset`), so round-trips no longer drop fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 45e8f3e commit eb55543

5 files changed

Lines changed: 122 additions & 27 deletions

File tree

substack/mdrender.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,14 @@ def _captioned_image(img: SyntaxTreeNode, api) -> Dict:
111111
pass
112112
# markdown-it stores the image alt text as the node's content, not in attrs.
113113
alt = img.content or img.attrs.get("alt") or None
114+
# Standard markdown image title `![alt](src "caption")` maps to Substack's caption node.
115+
title = img.attrs.get("title") or None
116+
caption = [nodes.text(title)] if title else None
114117
return nodes.captioned_image(
115118
src,
116119
alt=alt,
117120
href=getattr(img, "_link_href", None),
121+
caption=caption,
118122
)
119123

120124

substack/nodes.py

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class NodeType:
3232
FOOTNOTE = "footnote"
3333
FOOTNOTE_ANCHOR = "footnoteAnchor"
3434
CAPTIONED_IMAGE = "captionedImage"
35+
CAPTION = "caption"
3536

3637

3738
class MarkType:
@@ -99,31 +100,40 @@ def code_block(code: str, language: Optional[str] = None) -> Dict:
99100

100101

101102
def captioned_image(
102-
src: str, alt: Optional[str] = None, href: Optional[str] = None
103+
src: str,
104+
alt: Optional[str] = None,
105+
href: Optional[str] = None,
106+
caption: Optional[List[Dict]] = None,
107+
image_size: str = "normal",
103108
) -> Dict:
104-
return {
105-
"type": NodeType.CAPTIONED_IMAGE,
106-
"content": [
107-
{
108-
"type": "image2",
109-
"attrs": {
110-
"src": src,
111-
"fullscreen": False,
112-
"imageSize": "normal",
113-
"height": 819,
114-
"width": 1456,
115-
"resizeWidth": 728,
116-
"bytes": None,
117-
"alt": alt,
118-
"title": None,
119-
"type": None,
120-
"href": href,
121-
"belowTheFold": False,
122-
"internalRedirect": None,
123-
},
124-
}
125-
],
126-
}
109+
content: List[Dict] = [
110+
{
111+
"type": "image2",
112+
"attrs": {
113+
"src": src,
114+
"srcNoWatermark": None,
115+
"fullscreen": False,
116+
"imageSize": image_size,
117+
"height": 819,
118+
"width": 1456,
119+
"resizeWidth": 728 if image_size == "normal" else None,
120+
"bytes": None,
121+
"alt": alt,
122+
"title": None,
123+
"type": None,
124+
"href": href,
125+
"belowTheFold": False,
126+
"topImage": False,
127+
"internalRedirect": None,
128+
"isProcessing": False,
129+
"align": None,
130+
"offset": False,
131+
},
132+
}
133+
]
134+
if caption:
135+
content.append({"type": NodeType.CAPTION, "content": caption})
136+
return {"type": NodeType.CAPTIONED_IMAGE, "content": content}
127137

128138

129139
def footnote_anchor(number: int) -> Dict:

tests/substack/fixtures/full_features.expected.json

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,7 @@
555555
"type": "image2",
556556
"attrs": {
557557
"src": "<SRC>",
558+
"srcNoWatermark": null,
558559
"fullscreen": false,
559560
"imageSize": "normal",
560561
"height": "<DIM>",
@@ -566,7 +567,11 @@
566567
"type": null,
567568
"href": null,
568569
"belowTheFold": false,
569-
"internalRedirect": null
570+
"topImage": false,
571+
"internalRedirect": null,
572+
"isProcessing": false,
573+
"align": null,
574+
"offset": false
570575
}
571576
}
572577
]
@@ -578,6 +583,7 @@
578583
"type": "image2",
579584
"attrs": {
580585
"src": "<SRC>",
586+
"srcNoWatermark": null,
581587
"fullscreen": false,
582588
"imageSize": "normal",
583589
"height": "<DIM>",
@@ -589,7 +595,11 @@
589595
"type": null,
590596
"href": "https://example.com/target",
591597
"belowTheFold": false,
592-
"internalRedirect": null
598+
"topImage": false,
599+
"internalRedirect": null,
600+
"isProcessing": false,
601+
"align": null,
602+
"offset": false
593603
}
594604
}
595605
]
@@ -601,6 +611,44 @@
601611
"type": "image2",
602612
"attrs": {
603613
"src": "<SRC>",
614+
"srcNoWatermark": null,
615+
"fullscreen": false,
616+
"imageSize": "normal",
617+
"height": "<DIM>",
618+
"width": "<DIM>",
619+
"resizeWidth": "<DIM>",
620+
"bytes": null,
621+
"alt": "Captioned with title",
622+
"title": null,
623+
"type": null,
624+
"href": null,
625+
"belowTheFold": false,
626+
"topImage": false,
627+
"internalRedirect": null,
628+
"isProcessing": false,
629+
"align": null,
630+
"offset": false
631+
}
632+
},
633+
{
634+
"type": "caption",
635+
"content": [
636+
{
637+
"type": "text",
638+
"text": "Caption text from markdown title"
639+
}
640+
]
641+
}
642+
]
643+
},
644+
{
645+
"type": "captionedImage",
646+
"content": [
647+
{
648+
"type": "image2",
649+
"attrs": {
650+
"src": "<SRC>",
651+
"srcNoWatermark": null,
604652
"fullscreen": false,
605653
"imageSize": "normal",
606654
"height": "<DIM>",
@@ -612,7 +660,11 @@
612660
"type": null,
613661
"href": null,
614662
"belowTheFold": false,
615-
"internalRedirect": null
663+
"topImage": false,
664+
"internalRedirect": null,
665+
"isProcessing": false,
666+
"align": null,
667+
"offset": false
616668
}
617669
}
618670
]

tests/substack/fixtures/full_features.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ plain code block without a language
5959

6060
[![Linked image alt](https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png)](https://example.com/target)
6161

62+
![Captioned with title](https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png "Caption text from markdown title")
63+
6264
![A locally uploaded image](local_image.png)
6365

6466
`inline code with [^1] inside stays literal`

tests/substack/test_from_markdown_features.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,37 @@ def test_image(self):
133133
post.from_markdown("![alt](https://example.com/img.png)")
134134
block = body(post)[0]
135135
assert block["type"] == "captionedImage"
136+
assert len(block["content"]) == 1
136137
assert block["content"][0]["type"] == "image2"
137138
attrs = block["content"][0]["attrs"]
138139
assert attrs["src"] == "https://example.com/img.png"
139140
assert attrs["alt"] == "alt"
141+
assert attrs["href"] is None
142+
assert attrs["imageSize"] == "normal"
143+
assert attrs["srcNoWatermark"] is None
144+
assert attrs["topImage"] is False
145+
assert attrs["isProcessing"] is False
146+
assert attrs["align"] is None
147+
assert attrs["offset"] is False
148+
149+
def test_image_with_caption(self):
150+
post = make_post()
151+
post.from_markdown('![alt](https://example.com/img.png "My caption")')
152+
block = body(post)[0]
153+
assert block["type"] == "captionedImage"
154+
assert len(block["content"]) == 2
155+
assert block["content"][0]["type"] == "image2"
156+
assert block["content"][0]["attrs"]["alt"] == "alt"
157+
caption = block["content"][1]
158+
assert caption["type"] == "caption"
159+
assert caption["content"] == [{"type": "text", "text": "My caption"}]
160+
161+
def test_image_without_caption_has_no_caption_node(self):
162+
post = make_post()
163+
post.from_markdown("![alt](https://example.com/img.png)")
164+
block = body(post)[0]
165+
types = [n["type"] for n in block["content"]]
166+
assert "caption" not in types
140167

141168
def test_linked_image(self):
142169
post = make_post()

0 commit comments

Comments
 (0)