Skip to content

Commit 2b7e7b1

Browse files
committed
Fix nested color and highlight style precedence
1 parent 003bb84 commit 2b7e7b1

4 files changed

Lines changed: 142 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
back: `textStyle` becomes a `<span data-color>` with inline `color`,
1414
`highlight` becomes a `<mark data-color>` with inline `background-color`).
1515

16+
### Fixed
17+
18+
- Nested color and highlight spans override inherited styles of the same type
19+
without creating duplicate ProseMirror marks or changing sibling styles.
20+
Repeated style properties use the last nonempty declaration.
21+
1622
## 0.10.0
1723

1824
### Added

‎docs/markdown.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ one span:
5252
[both]{style="color:#ffffff; background-color:#000000"}
5353
```
5454

55+
When a property is repeated, its last nonempty declaration wins. Nested spans
56+
override the same property on their parent while inheriting other styles.
57+
Styles on an inner span do not affect its siblings.
58+
5559
A `{...}` block elsewhere (after an image, link, or inline code) is left as
5660
plain text; the syntax is only recognized immediately after a `[span]`.
5761

‎substack/mdrender.py‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -172,20 +172,21 @@ def _style_marks(attrs: Dict) -> List[Dict]:
172172
Only `color` and `background-color` declarations are recognized; any
173173
other declaration is ignored rather than rejected, since `{...}` spans
174174
are also used (via the attrs plugin) for plain CSS-style attributes in
175-
general, not just color.
175+
general, not just color. The last nonempty declaration for each property
176+
wins, so each style contributes at most one mark of its type.
176177
"""
177178
style = attrs.get("style", "")
178-
marks: List[Dict] = []
179+
marks: Dict[str, Dict] = {}
179180
for declaration in style.split(";"):
180181
prop, _, value = declaration.partition(":")
181182
prop, value = prop.strip().lower(), value.strip()
182183
if not value:
183184
continue
184185
if prop == "color":
185-
marks.append(nodes.text_style_mark(value))
186+
marks[MarkType.TEXT_STYLE] = nodes.text_style_mark(value)
186187
elif prop == "background-color":
187-
marks.append(nodes.highlight_mark(value))
188-
return marks
188+
marks[MarkType.HIGHLIGHT] = nodes.highlight_mark(value)
189+
return list(marks.values())
189190

190191

191192
def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[Dict]:
@@ -206,7 +207,12 @@ def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[D
206207
href = child.attrs.get("href", "")
207208
out.extend(_render_inline(child, marks + [nodes.link_mark(href)], ctx))
208209
elif t == "span":
209-
out.extend(_render_inline(child, marks + _style_marks(child.attrs), ctx))
210+
# Replace inherited styles without mutating the parent/sibling marks.
211+
span_marks = {mark["type"]: mark for mark in marks}
212+
span_marks.update(
213+
(mark["type"], mark) for mark in _style_marks(child.attrs)
214+
)
215+
out.extend(_render_inline(child, list(span_marks.values()), ctx))
210216
elif t in ("softbreak", "hardbreak"):
211217
out.append(nodes.text(" ", marks))
212218
elif t == "footnote_ref":
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Style precedence through the public Markdown import and export paths."""
2+
3+
import pytest
4+
5+
from substack.mdexport import document_to_markdown
6+
from substack.mdrender import markdown_to_doc
7+
from substack.post import Post
8+
9+
10+
def render(markdown):
11+
post = Post("T", "", user_id=1)
12+
post.from_markdown(markdown)
13+
return post.draft_body
14+
15+
16+
def marks_for(document, text):
17+
node = next(n for n in document["content"][0]["content"] if n["text"] == text)
18+
return node.get("marks", [])
19+
20+
21+
@pytest.mark.parametrize(
22+
("property_name", "mark_type"),
23+
[("color", "textStyle"), ("background-color", "highlight")],
24+
)
25+
@pytest.mark.parametrize(
26+
"declarations",
27+
[
28+
"{prop}:red; {prop}:blue",
29+
"{prop}:red; {prop}:green; {prop}:blue",
30+
"{prop}:red; {prop}:; font-weight:bold; {prop}:blue; {prop}:",
31+
"{prop}:red; {prop_upper} : blue",
32+
],
33+
)
34+
def test_last_nonempty_style_declaration_wins(property_name, mark_type, declarations):
35+
style = declarations.format(prop=property_name, prop_upper=property_name.upper())
36+
document = render(f'[x]{{style="{style}"}}')
37+
assert marks_for(document, "x") == [{"type": mark_type, "attrs": {"color": "blue"}}]
38+
39+
40+
@pytest.mark.parametrize(
41+
("property_name", "mark_type"),
42+
[("color", "textStyle"), ("background-color", "highlight")],
43+
)
44+
def test_nested_style_overrides_without_leaking_to_siblings(property_name, mark_type):
45+
document = render(
46+
f'[before [inner]{{style="{property_name}:red"}} after]'
47+
f'{{style="{property_name}:blue"}} outside'
48+
)
49+
for text in ("before ", " after"):
50+
assert marks_for(document, text) == [
51+
{"type": mark_type, "attrs": {"color": "blue"}}
52+
]
53+
assert marks_for(document, "inner") == [
54+
{"type": mark_type, "attrs": {"color": "red"}}
55+
]
56+
assert marks_for(document, " outside") == []
57+
58+
59+
def test_nested_styles_preserve_other_marks_and_round_trip():
60+
document = render(
61+
"[before [**bold** and [link](https://example.com) and `code`]"
62+
'{style="color:green; color:red"} after]'
63+
'{style="color:blue; background-color:yellow"}'
64+
)
65+
expected_styles = {
66+
"textStyle": {"color": "red"},
67+
"highlight": {"color": "yellow"},
68+
}
69+
for text, extra_type in (("bold", "strong"), ("link", "link"), ("code", "code")):
70+
marks = marks_for(document, text)
71+
assert len(marks) == 3
72+
assert {
73+
m["type"]: m["attrs"] for m in marks if "attrs" in m and m["type"] != "link"
74+
} == expected_styles
75+
assert any(m["type"] == extra_type for m in marks)
76+
assert next(m for m in marks_for(document, "link") if m["type"] == "link")[
77+
"attrs"
78+
] == {"href": "https://example.com"}
79+
80+
markdown, unsupported = document_to_markdown(document)
81+
assert unsupported == []
82+
assert markdown_to_doc(markdown) == document["content"]
83+
84+
85+
def test_three_levels_override_color_and_highlight_independently():
86+
document = render(
87+
'[outer [middle [inner]{style="color:red"} middle-end]'
88+
'{style="background-color:green"} outer-end]'
89+
'{style="color:blue; background-color:yellow"}'
90+
)
91+
for text, color, highlight in (
92+
("outer ", "blue", "yellow"),
93+
("middle ", "blue", "green"),
94+
("inner", "red", "green"),
95+
(" middle-end", "blue", "green"),
96+
(" outer-end", "blue", "yellow"),
97+
):
98+
marks = marks_for(document, text)
99+
assert len(marks) == 2
100+
assert {m["type"]: m["attrs"]["color"] for m in marks} == {
101+
"textStyle": color,
102+
"highlight": highlight,
103+
}
104+
105+
106+
def test_unrecognized_or_empty_inner_styles_keep_inherited_styles():
107+
document = render(
108+
'[outer [inner]{style="color:; background-color:; font-weight:bold"}]'
109+
'{style="color:blue; background-color:yellow"}'
110+
)
111+
assert document["content"][0]["content"] == [
112+
{
113+
"type": "text",
114+
"text": "outer inner",
115+
"marks": [
116+
{"type": "textStyle", "attrs": {"color": "blue"}},
117+
{"type": "highlight", "attrs": {"color": "yellow"}},
118+
],
119+
}
120+
]

0 commit comments

Comments
 (0)