Skip to content

Commit b77c524

Browse files
committed
Apply code review fixes: tab stops, depth guard, demote guard, docs
1 parent 7505b36 commit b77c524

8 files changed

Lines changed: 146 additions & 11 deletions

File tree

draftjs_exporter/contentstate_filter/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,12 @@ def _run_actions(value: Any, actions: list[FilterAction], kind: str) -> Any:
160160
current = None
161161
continue
162162
if action == "demote":
163+
if not isinstance(current, dict) or current.get("type") not in (
164+
HEADER_DEMOTION
165+
):
166+
raise ConfigException(
167+
"Filter callback must return a demotable header block"
168+
)
163169
current = {**current, "type": HEADER_DEMOTION[current["type"]]}
164170
continue
165171
current = action(current)

draftjs_exporter/markdown_importer/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def __init__(self, config: ImporterConfig | None = None) -> None:
4444
config = {}
4545
parser_class = import_string(config.get("parser", DEFAULT_PARSER))
4646
self.parser: MarkdownParser = parser_class(config.get("parser_config"))
47-
self.filter = ContentStateFilter(config.get("filter_rules"))
47+
self.filter: ContentStateFilter = ContentStateFilter(config.get("filter_rules"))
4848

4949
def import_markdown(self, markdown: str) -> ContentState:
5050
"""Parse Markdown and apply filter rules.

draftjs_exporter/markdown_parser/__init__.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,46 @@ class ParserConfig(TypedDict, total=False):
2121
"""Options controlling which Markdown constructs are recognized."""
2222

2323
headings: bool
24+
"""Parse ATX headings (default: True)."""
25+
2426
blockquote: bool
27+
"""Parse ``>`` blockquotes (default: True)."""
28+
2529
code_fenced: bool
30+
"""Parse fenced code blocks (default: True)."""
31+
2632
thematic_break: bool
33+
"""Parse thematic breaks (default: True)."""
34+
2735
unordered_list: bool
36+
"""Parse unordered lists (default: True)."""
37+
2838
ordered_list: bool
39+
"""Parse ordered lists (default: True)."""
40+
2941
emphasis: bool
42+
"""Parse bold and italic delimiters (default: True)."""
43+
3044
code_inline: bool
45+
"""Parse backtick code spans (default: True)."""
46+
3147
links: bool
48+
"""Parse ``[label](url)`` links (default: True)."""
49+
3250
images: bool
51+
"""Parse ``![alt](url)`` images (default: True)."""
52+
3353
line_breaks: bool
54+
"""Strip two-space hard break markers (default: True)."""
55+
3456
link_resolvers: list[EntityResolver]
57+
"""Resolver chain for link URLs (default: empty, uses ``LINK`` with the URL)."""
58+
3559
image_resolvers: list[EntityResolver]
60+
"""Resolver chain for image URLs (default: empty, uses ``IMAGE`` with ``src``/``alt``)."""
61+
3662
inline_html_styles: dict[str, str]
63+
"""Whitelist of HTML tags mapped to inline styles, e.g. ``{"sup": "SUPERSCRIPT"}``."""
3764

3865

3966
class MarkdownParser:
@@ -47,6 +74,8 @@ class MarkdownParser:
4774

4875
__slots__ = ("config",)
4976

77+
config: ParserConfig
78+
5079
def __init__(self, config: ParserConfig | None = None) -> None:
5180
"""Initialize the parser with the given configuration.
5281

draftjs_exporter/markdown_parser/blocks.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,28 @@
4141
"""Heading block types indexed by ATX level minus one."""
4242

4343

44+
def _indent_width(text: str, tab_size: int = 4) -> int:
45+
"""Compute the column width of indentation, expanding tabs to tab stops.
46+
47+
Per CommonMark, a tab advances the column position to the next
48+
multiple of 4 rather than counting as a fixed number of spaces.
49+
50+
Parameters:
51+
text: The leading whitespace of a line.
52+
tab_size: The tab stop interval.
53+
54+
Returns:
55+
The column position after the indentation.
56+
"""
57+
col = 0
58+
for ch in text:
59+
if ch == "\t":
60+
col += tab_size - (col % tab_size)
61+
else:
62+
col += 1
63+
return col
64+
65+
4466
class BlockParser:
4567
"""Parse Markdown line by line into Draft.js blocks.
4668
@@ -309,7 +331,7 @@ def _parse_list(self, lines: list[str], i: int) -> int:
309331
match = unordered or ordered
310332
if match is None:
311333
break
312-
indent = len(match.group(1).replace("\t", " "))
334+
indent = _indent_width(match.group(1))
313335
while stack and indent < stack[-1]:
314336
stack.pop()
315337
if not stack or indent > stack[-1]:

draftjs_exporter/markdown_parser/inline.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
TAG_RE = re.compile(r"<([a-zA-Z][a-zA-Z0-9]*)>")
3030
"""Matches an HTML opening tag without attributes."""
3131

32+
MAX_INLINE_DEPTH = 100
33+
"""Maximum nesting depth for inline constructs before rejecting input."""
34+
3235

3336
class InlineParser:
3437
"""Parse inline Markdown constructs into text with style/entity ranges.
@@ -137,6 +140,7 @@ def _resolve_entity(
137140
try:
138141
resolution: EntityResolution = resolve(resolvers, url, label, default)
139142
except MarkdownParseError:
143+
# Propagate without wrapping; unexpected exceptions are wrapped below.
140144
raise
141145
except Exception as err:
142146
raise MarkdownParseError(
@@ -156,8 +160,18 @@ def _resolve_entity(
156160
entity_type, data, resolution.get("mutability", "MUTABLE")
157161
)
158162

159-
def _parse(self, text: str) -> tuple[str, list[Span]]:
160-
"""Scan text, returning output characters and annotation spans."""
163+
def _parse(self, text: str, depth: int = 0) -> tuple[str, list[Span]]:
164+
"""Scan text, returning output characters and annotation spans.
165+
166+
Parameters:
167+
text: The text to scan.
168+
depth: Current recursion depth for nested constructs.
169+
170+
Raises:
171+
MarkdownParseError: If nesting exceeds ``MAX_INLINE_DEPTH``.
172+
"""
173+
if depth > MAX_INLINE_DEPTH:
174+
raise MarkdownParseError("Inline nesting too deep")
161175
out: list[str] = []
162176
spans: list[Span] = []
163177
i = 0
@@ -207,7 +221,7 @@ def _parse(self, text: str) -> tuple[str, list[Span]]:
207221
result = self._link_target(text, i)
208222
if result is not None:
209223
label_src, url, end = result
210-
label_plain, label_spans = self._parse(label_src)
224+
label_plain, label_spans = self._parse(label_src, depth + 1)
211225
start = len(out)
212226
out.extend(label_plain)
213227
spans.extend(
@@ -223,14 +237,14 @@ def _parse(self, text: str) -> tuple[str, list[Span]]:
223237

224238
# Emphasis: * _ ** __ *** ___
225239
if self.emphasis and ch in "*_":
226-
consumed = self._parse_emphasis(text, i, out, spans)
240+
consumed = self._parse_emphasis(text, i, out, spans, depth)
227241
if consumed is not None:
228242
i = consumed
229243
continue
230244

231245
# Whitelisted inline HTML tags.
232246
if ch == "<" and self.inline_html_styles:
233-
consumed = self._parse_inline_html(text, i, out, spans)
247+
consumed = self._parse_inline_html(text, i, out, spans, depth)
234248
if consumed is not None:
235249
i = consumed
236250
continue
@@ -275,7 +289,7 @@ def _link_target(text: str, i: int) -> tuple[str, str, int] | None:
275289
return text[i + 1 : close], text[close + 2 : paren], paren + 1
276290

277291
def _parse_emphasis(
278-
self, text: str, i: int, out: list[str], spans: list[Span]
292+
self, text: str, i: int, out: list[str], spans: list[Span], depth: int
279293
) -> int | None:
280294
"""Parse an emphasis delimiter run at index i.
281295
@@ -287,6 +301,7 @@ def _parse_emphasis(
287301
i: Index of the first delimiter character.
288302
out: Output characters accumulated so far.
289303
spans: Spans accumulated so far.
304+
depth: Current recursion depth for nested constructs.
290305
291306
Returns:
292307
The index after the closing delimiter, or None when the run
@@ -305,7 +320,7 @@ def _parse_emphasis(
305320
end = self._find_closing(text, i + run, marker)
306321
if end == -1:
307322
return None
308-
inner_plain, inner_spans = self._parse(text[i + run : end])
323+
inner_plain, inner_spans = self._parse(text[i + run : end], depth + 1)
309324
start = len(out)
310325
out.extend(inner_plain)
311326
spans.extend(
@@ -349,7 +364,7 @@ def _find_closing(text: str, start: int, marker: str) -> int:
349364
return end
350365

351366
def _parse_inline_html(
352-
self, text: str, i: int, out: list[str], spans: list[Span]
367+
self, text: str, i: int, out: list[str], spans: list[Span], depth: int
353368
) -> int | None:
354369
"""Parse a whitelisted inline HTML tag at index i.
355370
@@ -362,6 +377,7 @@ def _parse_inline_html(
362377
i: Index of the ``<`` character.
363378
out: Output characters accumulated so far.
364379
spans: Spans accumulated so far.
380+
depth: Current recursion depth for nested constructs.
365381
366382
Returns:
367383
The index after the closing tag, or None when the tag does
@@ -378,7 +394,7 @@ def _parse_inline_html(
378394
end = text.find(closing, match.end())
379395
if end == -1:
380396
return None
381-
inner_plain, inner_spans = self._parse(text[match.end() : end])
397+
inner_plain, inner_spans = self._parse(text[match.end() : end], depth + 1)
382398
start = len(out)
383399
out.extend(inner_plain)
384400
spans.extend(

tests/contentstate_filter/test_filter.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,33 @@ def test_entity_callback_without_type_rejected(self):
228228
ContentStateFilter(
229229
[{"type": "entity", "match": "LINK", "action": lambda e: {}}]
230230
).apply(cs)
231+
232+
233+
class TestDemoteChainGuards(unittest.TestCase):
234+
def test_demote_after_callback_returning_bad_block_rejected(self):
235+
from draftjs_exporter.error import ConfigException
236+
237+
cs = cs_with_blocks(make_block("header-one"))
238+
with self.assertRaises(ConfigException):
239+
ContentStateFilter(
240+
[
241+
{"type": "block", "match": "header-one", "action": lambda b: {}},
242+
{"type": "block", "match": "header-one", "action": "demote"},
243+
]
244+
).apply(cs)
245+
246+
def test_demote_after_callback_changing_type_rejected(self):
247+
from draftjs_exporter.error import ConfigException
248+
249+
cs = cs_with_blocks(make_block("header-one"))
250+
with self.assertRaises(ConfigException):
251+
ContentStateFilter(
252+
[
253+
{
254+
"type": "block",
255+
"match": "header-one",
256+
"action": lambda b: {**b, "type": "unstyled"},
257+
},
258+
{"type": "block", "match": "header-one", "action": "demote"},
259+
]
260+
).apply(cs)

tests/markdown_parser/test_blocks.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,15 @@ def bad(url, label):
279279
parser.parse("[a](/b)")
280280
self.assertEqual(ctx.exception.line, 99)
281281
self.assertEqual(ctx.exception.message, "custom failure")
282+
283+
284+
class TestTabIndentation(unittest.TestCase):
285+
def test_tab_advances_to_next_tab_stop(self):
286+
# " \t" is 4 columns per CommonMark (tab from column 2 advances
287+
# to column 4), the same indent as four spaces.
288+
cs = parse("- a\n - b\n \t- c")
289+
self.assertEqual([b["depth"] for b in cs["blocks"]], [0, 1, 1])
290+
291+
def test_lone_tab_is_one_tab_stop(self):
292+
cs = parse("- a\n\t- b")
293+
self.assertEqual([b["depth"] for b in cs["blocks"]], [0, 1])

tests/markdown_parser/test_inline_html.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,23 @@ def test_empty_whitelist_means_literal(self):
4444
text, styles, _ = make_parser().parse("<sup>2</sup>")
4545
self.assertEqual(text, "<sup>2</sup>")
4646
self.assertEqual(styles, [])
47+
48+
49+
class TestNestingDepth(unittest.TestCase):
50+
def test_depth_guard_rejects_excessive_recursion(self):
51+
from draftjs_exporter.error import MarkdownParseError
52+
from draftjs_exporter.markdown_parser.inline import MAX_INLINE_DEPTH
53+
54+
# The parser's find-first-closer semantics make deep nesting
55+
# unreachable from real input (mis-paired constructs fall back to
56+
# literal text), so the guard is exercised directly.
57+
parser = make_parser(inline_html_styles=SUP_SUB)
58+
with self.assertRaises(MarkdownParseError):
59+
parser._parse("x", depth=MAX_INLINE_DEPTH + 1)
60+
61+
def test_moderate_nesting_still_parses(self):
62+
text, styles, _ = make_parser(inline_html_styles=SUP_SUB).parse(
63+
"<sup><sub>x</sub></sup>"
64+
)
65+
self.assertEqual(text, "x")
66+
self.assertEqual(len(styles), 2)

0 commit comments

Comments
 (0)