Skip to content

Commit 4e398c6

Browse files
authored
fix: escape literal CommonMark syntax characters in output (#915)
Stray CommonMark-meaningful characters in extracted prose - *, _, `, ~, [, ], < - were emitted verbatim, causing renderers to misread them as emphasis, code spans, link/image brackets, or raw HTML/autolinks. A leading #, >, -, +, or digit+.")" at the start of a paragraph, list item, or blockquote line was likewise misread as heading, blockquote, or list syntax. Update the one real-world fixture whose expected output contained a literal asterisk. Escape these via a tree-mutation pass (_escape_inline_specials_tree) that runs before markdown markup is generated, so real **bold**/*italic*/~~strikethrough~~ syntax and link/image brackets are never touched. Verbatim <code> and inline-code (#t) spans are skipped since their content must stay literal, and plain-text mode (include_formatting=False) is unaffected. A separate _escape_block_start pass handles line-leading heading/blockquote/list markers for p/quote/item elements only, since table cells are inline-only in GFM. _md_link now escapes only not-yet-escaped brackets so it stays safe whether or not the tree-wide pass already ran. The del/~~ escaping now relies on the tree-wide pass instead of its own ad hoc replace. Add test_markdown_asterisk_escaping and test_markdown_special_char_escaping covering paragraphs, headings, list items, table cells, links, images, verbatim code, nested formatting, backslash round-tripping, and block-start marker escaping. Co-authored-by: scott <scott>
1 parent e90b0ef commit 4e398c6

3 files changed

Lines changed: 254 additions & 9 deletions

File tree

tests/realworld_tests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -736,7 +736,7 @@ def test_extract(xmloutput, formatting):
736736
if formatting is False:
737737
assert "von The unbelievable Machine Company (*um) zur Verfügung gestellt." in result
738738
else:
739-
assert "von **The unbelievable Machine Company (*um)** zur Verfügung gestellt.\n" in result
739+
assert "von **The unbelievable Machine Company (\\*um)** zur Verfügung gestellt.\n" in result
740740
assert "Matthias Weber ist ERP-Experte mit langjähriger Berufserfahrung." not in result
741741
assert "Die Top 5 digitalen Trends für den Mittelstand" not in result
742742
assert ", leading edge," not in result # and 'Lesen Sie hier einen weiteren spannenden Beitrag' not in result

tests/unit_tests.py

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3270,7 +3270,7 @@ def test_markdown_escaping():
32703270
# ~~ inside del content must not close the strikethrough early
32713271
tree = etree.fromstring(b"<body><p><del>a~~b</del></p></body>")
32723272
result = xml.xmltotxt(tree, include_formatting=True)
3273-
assert "~~a~\\~b~~" in result
3273+
assert "~~a\\~\\~b~~" in result
32743274

32753275
# del as a direct child of a table cell (no enclosing p)
32763276
result = extract(
@@ -3314,6 +3314,174 @@ def test_markdown_escaping():
33143314
assert xml.xmltotxt(etree.fromstring(struck_img), True).strip() == "~~x ![A](i.jpg) y~~"
33153315

33163316

3317+
def test_markdown_asterisk_escaping():
3318+
"Literal '*' in source text must be escaped so CommonMark renders it literally, not as emphasis."
3319+
3320+
def md(b):
3321+
return xml.xmltotxt(etree.fromstring(b), include_formatting=True)
3322+
3323+
# a bare asterisk in plain paragraph text must not turn into emphasis
3324+
assert (
3325+
md(b"<body><p>This *should* not be italic and 3*4=12</p></body>").strip()
3326+
== "This \\*should\\* not be italic and 3\\*4=12"
3327+
)
3328+
3329+
# a real <hi> bold marker must survive untouched next to escaped literal asterisks
3330+
tree = etree.fromstring(b'<body><p><hi rend="#b">bold</hi> and *literal* asterisks</p></body>')
3331+
assert xml.xmltotxt(tree, include_formatting=True).strip() == "**bold** and \\*literal\\* asterisks"
3332+
3333+
# asterisks inside a heading are escaped, the '#' prefix is untouched
3334+
assert md(b'<body><head rend="h2">Title *with* asterisk</head></body>').strip() == "## Title \\*with\\* asterisk"
3335+
3336+
# asterisks inside a list item are escaped, the '- ' marker is untouched
3337+
assert md(b"<body><list><item>list item *text*</item></list></body>").strip() == "- list item \\*text\\*"
3338+
3339+
# asterisks inside table cell text are escaped alongside the pre-existing pipe escaping
3340+
tree = etree.fromstring(b"<body><table><row><cell>a*b|c</cell></row></table></body>")
3341+
assert "a\\*b\\|c" in xml.xmltotxt(tree, include_formatting=True)
3342+
3343+
# asterisks inside link text are escaped, the [text](url) syntax is untouched
3344+
tree = etree.fromstring(b'<body><p><ref target="http://x.com">link *text*</ref></p></body>')
3345+
assert "[link \\*text\\*](http://x.com)" in xml.xmltotxt(tree, include_formatting=True)
3346+
3347+
# asterisks inside image alt text are escaped
3348+
tree = etree.fromstring(b'<body><graphic src="i.png" alt="a*b"/></body>')
3349+
assert "![a\\*b](i.png)" in xml.xmltotxt(tree, include_formatting=True)
3350+
3351+
# asterisks inside struck-through (del) text are escaped, the ~~ markers are untouched
3352+
tree = etree.fromstring(b"<body><p><del>a*b</del></p></body>")
3353+
assert xml.xmltotxt(tree, include_formatting=True).strip() == "~~a\\*b~~"
3354+
3355+
# a bold link keeps both its ** markers and its brackets: nested markup must not be
3356+
# re-escaped as if it were literal source text
3357+
tree = etree.fromstring(b'<body><p><ref target="http://x.com"><hi rend="#b">bold link</hi></ref></p></body>')
3358+
assert xml.xmltotxt(tree, include_formatting=True).strip() == "[**bold link**](http://x.com)"
3359+
3360+
# asterisks inside verbatim code (block and inline) must NOT be escaped
3361+
assert xml.xmltotxt(etree.fromstring(b"<body><code>a * b</code></body>"), True).strip() == "`a * b`"
3362+
tree = etree.fromstring(b'<body><p><hi rend="#t">a*b</hi></p></body>')
3363+
assert xml.xmltotxt(tree, include_formatting=True).strip() == "`a*b`"
3364+
3365+
# a run of consecutive asterisks (e.g. a plain-text divider) must not be read as emphasis
3366+
assert md(b"<body><p>*** section break ***</p></body>").strip() == "\\*\\*\\* section break \\*\\*\\*"
3367+
3368+
# an even-length run must also be escaped character-by-character, not skipped as a pair
3369+
assert md(b"<body><p>**not bold**</p></body>").strip() == "\\*\\*not bold\\*\\*"
3370+
3371+
# a literal '*' touching a real <hi> open/close boundary (immediately inside or outside
3372+
# the tag, singly or doubled) must be escaped without disturbing the real emphasis
3373+
# marker. #i is the higher-risk case since its marker is a single '*', the same
3374+
# character being escaped, while #b's '**' is a different-looking two-char sequence.
3375+
for rend, marker in (("#i", "*"), ("#b", "**")):
3376+
# just outside the opening tag
3377+
assert md(f'<body><p>*<hi rend="{rend}">x</hi></p></body>'.encode()).strip() == f"\\*{marker}x{marker}"
3378+
# just inside the opening tag (first character of the hi's own text)
3379+
assert md(f'<body><p><hi rend="{rend}">*x</hi></p></body>'.encode()).strip() == f"{marker}\\*x{marker}"
3380+
# just outside the closing tag
3381+
assert md(f'<body><p><hi rend="{rend}">x</hi>*</p></body>'.encode()).strip() == f"{marker}x{marker}\\*"
3382+
# just inside the closing tag (last character of the hi's own text)
3383+
assert md(f'<body><p><hi rend="{rend}">x*</hi></p></body>'.encode()).strip() == f"{marker}x\\*{marker}"
3384+
# wrapping the text from inside the tag on both sides
3385+
assert md(f'<body><p><hi rend="{rend}">*x*</hi></p></body>'.encode()).strip() == f"{marker}\\*x\\*{marker}"
3386+
# wrapping the whole <hi> element from outside on both sides
3387+
assert md(f'<body><p>*<hi rend="{rend}">x</hi>*</p></body>'.encode()).strip() == f"\\*{marker}x{marker}\\*"
3388+
# a literal '**' run just inside the tag on both sides
3389+
assert md(f'<body><p><hi rend="{rend}">**x**</hi></p></body>'.encode()).strip() == f"{marker}\\*\\*x\\*\\*{marker}"
3390+
# a literal '**' run just outside the tag on both sides
3391+
assert md(f'<body><p>**<hi rend="{rend}">x</hi>**</p></body>'.encode()).strip() == f"\\*\\*{marker}x{marker}\\*\\*"
3392+
3393+
# a pre-existing literal backslash immediately before an asterisk must round-trip:
3394+
# rendered output should still show exactly one backslash then one asterisk
3395+
tree = etree.fromstring(b"<body><p>Edge case: a\\*b</p></body>")
3396+
assert xml.xmltotxt(tree, include_formatting=True).strip() == "Edge case: a\\\\\\*b"
3397+
3398+
# include_formatting=False is plain-text mode: asterisks must be left completely alone
3399+
assert xml.xmltotxt(etree.fromstring(b"<body><p>This *stays* as-is</p></body>"), False).strip() == "This *stays* as-is"
3400+
3401+
3402+
def test_markdown_special_char_escaping():
3403+
"""Literal CommonMark metacharacters other than '*' must also be escaped so they render as
3404+
themselves: '_', '`', '[', ']', '<', '~' are ambiguous wherever they appear in prose, while
3405+
'#', '-', '+', '.'/')' after digits and '>' only matter at the very start of a fresh block
3406+
line (heading/list/blockquote syntax). Everything else (quotes, parens, punctuation) is inert
3407+
in body text and must be left alone.
3408+
"""
3409+
3410+
def md(b, include_formatting=True):
3411+
return xml.xmltotxt(etree.fromstring(b), include_formatting=include_formatting).strip()
3412+
3413+
# underscore, backtick, brackets and '<' are escaped anywhere in a paragraph
3414+
assert (
3415+
md(b"<body><p>a_b_c and `code` and [bracket] and &lt;tag&gt; text</p></body>")
3416+
== "a\\_b\\_c and \\`code\\` and \\[bracket\\] and \\<tag> text"
3417+
)
3418+
# a lone '~' is also escaped: GitHub renders a single tilde as strikethrough, not just '~~'
3419+
assert md(b"<body><p>~single~ tilde</p></body>") == "\\~single\\~ tilde"
3420+
3421+
# a leading '#' in a plain paragraph must not become an ATX heading
3422+
assert md(b"<body><p># not a heading</p></body>") == "\\# not a heading"
3423+
# a leading '>' in a plain paragraph must not become a blockquote
3424+
assert md(b"<body><p>&gt; not a quote</p></body>") == "\\> not a quote"
3425+
# a leading '-'/'+' in a plain paragraph must not become a bullet list
3426+
assert md(b"<body><p>- not a list</p></body>") == "\\- not a list"
3427+
assert md(b"<body><p>+ not a list</p></body>") == "\\+ not a list"
3428+
# a leading digit + '.'/')' in a plain paragraph must not become an ordered list
3429+
assert md(b"<body><p>1. not a list</p></body>") == "1\\. not a list"
3430+
assert md(b"<body><p>1) not a list</p></body>") == "1\\) not a list"
3431+
3432+
# the same markers are safe mid-line and must be left alone (only line-start is risky)
3433+
assert (
3434+
md(b"<body><p>mid-line - and 3.14 and 100% and word) stay put</p></body>")
3435+
== "mid-line - and 3.14 and 100% and word) stay put"
3436+
)
3437+
3438+
# a list item's own content starting with a marker-like character must not nest as a
3439+
# sub-list; the item's real '- '/'N. ' marker (added separately) is untouched
3440+
assert md(b"<body><list><item>- nested looking</item></list></body>") == "- \\- nested looking"
3441+
assert (
3442+
md(b'<body><list rend="ol"><item>2. nested ordered looking</item></list></body>') == "1. 2\\. nested ordered looking"
3443+
)
3444+
3445+
# a blockquote element's own leading '>' must also be escaped
3446+
assert md(b"<body><quote>&gt; quoted text with - marker</quote></body>") == "\\> quoted text with - marker"
3447+
3448+
# a heading's own '#' prefix already claims the line, so an inner leading '#' is inert
3449+
assert md(b'<body><head rend="h2"># nested hash in heading</head></body>') == "## # nested hash in heading"
3450+
3451+
# table cells are inline-only in GFM: a leading marker-like character there is never risky
3452+
assert "| - looks like a list |" in md(b"<body><table><row><cell>- looks like a list</cell></row></table></body>")
3453+
3454+
# inert punctuation (quotes, parens, and other characters with no CommonMark meaning in
3455+
# body text) must be left completely alone
3456+
inert = b"<body><p>quote &quot; apostrophe ' paren ( ) percent % dollar $ at @ caret ^ eq = brace { } colon : semi ; comma , bang !</p></body>"
3457+
assert (
3458+
md(inert)
3459+
== "quote \" apostrophe ' paren ( ) percent % dollar $ at @ caret ^ eq = brace { } colon : semi ; comma , bang !"
3460+
)
3461+
# '!' alone (not immediately escaping a '[') does not need its own escaping
3462+
assert md(b"<body><p>! not an image on its own</p></body>") == "! not an image on its own"
3463+
3464+
# verbatim code (block and inline) must not have any of these characters escaped
3465+
assert md(b"<body><code>a_b`c[d]e&lt;f&gt;</code></body>") == "``a_b`c[d]e<f>``"
3466+
assert md(b'<body><p><hi rend="#t">a_b`c</hi></p></body>') == "``a_b`c``"
3467+
3468+
# brackets in link/image text are still escaped so a false image ('![...]') can't form,
3469+
# without needing to escape '!' itself: an escaped '[' already blocks the image syntax
3470+
assert (
3471+
md(b'<body><p><ref target="http://x.com">![img]-like text</ref></p></body>') == "[!\\[img\\]-like text](http://x.com)"
3472+
)
3473+
3474+
# a pre-existing literal backslash immediately before an underscore must round-trip:
3475+
# rendered output should still show exactly one backslash then one underscore
3476+
assert md(b"<body><p>Edge case: a\\_b</p></body>") == "Edge case: a\\\\\\_b"
3477+
3478+
# include_formatting=False is plain-text mode: none of these characters are touched
3479+
assert (
3480+
md(b"<body><p>This _stays_ as-is with `code` [b] &lt;tag&gt;</p></body>", include_formatting=False)
3481+
== "This _stays_ as-is with `code` [b] <tag>"
3482+
)
3483+
3484+
33173485
def test_markdown_link_angle_bracket_targets():
33183486
"A '<' or '>' in a link/image target must stay inside the angle-bracket destination."
33193487
# each URL forces the angle-bracket form (space/paren/</>) and must round-trip:

trafilatura/xml.py

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,24 @@
8989
# preceding characters that already separate content, so no extra space/newline is needed
9090
SEPARATORS = frozenset((" ", "\n", "|", ""))
9191

92+
# CommonMark inline-syntax characters that are ambiguous wherever they appear in prose text:
93+
# emphasis/strikethrough (*, _, ~), code spans (`), link/image brackets ([, ]) and
94+
# autolinks/raw HTML (<). Unlike '#', '-', '+', '>' and '.'/')' after a digit run, these are
95+
# risky mid-line too, not just at the start of a block (see _escape_block_start for those).
96+
_INLINE_SPECIAL_CHARS = ("*", "_", "`", "~", "[", "]", "<")
97+
# each entry matches that character preceded by any run of backslashes already in the source text
98+
_INLINE_SPECIAL_RUN_RES = {ch: re.compile(r"\\*" + re.escape(ch)) for ch in _INLINE_SPECIAL_CHARS}
99+
100+
# a leading heading/blockquote/list marker, only meaningful at the very start of a block line
101+
_HEADING_START_RE = re.compile(r"^( {0,3})(#{1,6})(?=[ \t]|$)")
102+
_QUOTE_START_RE = re.compile(r"^( {0,3})(>)")
103+
_BULLET_START_RE = re.compile(r"^( {0,3})([-+])(?=[ \t]|$)")
104+
_ORDERED_START_RE = re.compile(r"^( {0,3})(\d{1,9})([.)])(?=[ \t]|$)")
105+
_BLOCK_START_RES = (_HEADING_START_RE, _QUOTE_START_RE, _BULLET_START_RE, _ORDERED_START_RE)
106+
107+
# an unescaped '[' or ']', used as a safety net for text that never went through the tree-wide pass
108+
_UNESCAPED_BRACKET_RE = re.compile(r"(?<!\\)([\[\]])")
109+
92110
# block \[...\] and inline \(...\) math; only matched pairs are converted
93111
_MATH_BLOCK_RE = re.compile(r"(?<!\S)\\\[(.+?)\\\]", re.DOTALL)
94112
_MATH_INLINE_RE = re.compile(r"\\\((.+?)\\\)")
@@ -408,6 +426,50 @@ def _convert_math_tree(element: _Element) -> None:
408426
child.tail = _convert_math(child.tail)
409427

410428

429+
def _escape_inline_specials(text: str) -> str:
430+
"""Escape literal CommonMark inline-syntax characters so they render as themselves rather than
431+
emphasis/strikethrough, a code span, link/image brackets or an autolink/raw HTML tag.
432+
433+
Any backslashes already sitting in front of a target character are doubled (so they still
434+
render as literal backslashes) before the new escaping backslash is added, which keeps the
435+
escape correct even if the source text happened to already contain an escaped occurrence.
436+
"""
437+
if not any(ch in text for ch in _INLINE_SPECIAL_CHARS):
438+
return text
439+
440+
def _escape_run(match: "re.Match[str]") -> str:
441+
run: str = match.group()
442+
return "\\" * (2 * run.count("\\") + 1) + run[-1]
443+
444+
for ch in _INLINE_SPECIAL_CHARS:
445+
if ch in text:
446+
text = _INLINE_SPECIAL_RUN_RES[ch].sub(_escape_run, text)
447+
return text
448+
449+
450+
def _escape_inline_specials_tree(element: _Element) -> None:
451+
"Escape literal inline-syntax characters in text/tails in place, leaving code subtrees untouched (their content is verbatim)."
452+
# code content is verbatim: skip the whole subtree
453+
if element.tag == "code" or (element.tag == "hi" and HI_FORMATTING.get(element.get("rend") or "") == "`"):
454+
return
455+
if element.text:
456+
element.text = _escape_inline_specials(element.text)
457+
for child in element:
458+
_escape_inline_specials_tree(child)
459+
if child.tail: # a code element's tail is prose, so it is still escaped
460+
child.tail = _escape_inline_specials(child.tail)
461+
462+
463+
def _escape_block_start(text: str) -> str:
464+
"Escape a leading heading/blockquote/list marker so a fresh paragraph/item line isn't misread as block syntax."
465+
for regex in _BLOCK_START_RES:
466+
match = regex.match(text)
467+
if match and match.lastindex is not None: # every _BLOCK_START_RES pattern has a marker group
468+
pos = match.start(match.lastindex)
469+
return f"{text[:pos]}\\{text[pos:]}"
470+
return text
471+
472+
411473
def _last_char(returnlist: list[str]) -> str:
412474
"Last character emitted so far, or '' if nothing yet."
413475
return returnlist[-1][-1:] if returnlist else ""
@@ -432,8 +494,14 @@ def _list_marker(element: _Element, in_item: bool | None = None, include_formatt
432494

433495

434496
def _md_link(text: str, url: str | None, image: bool = False) -> str:
435-
"Markdown link/image with escaped text and a CommonMark-safe target."
436-
esc = text.replace("[", "\\[").replace("]", "\\]")
497+
"""Markdown link/image with escaped text and a CommonMark-safe target.
498+
499+
Text coming from the tree-wide escaping pass already has its brackets escaped; only
500+
brackets not already escaped are touched here, so this is safe to call on both
501+
pre-escaped and raw text (e.g. link text is linkified even when include_formatting is
502+
False, in which case the tree-wide pass never ran).
503+
"""
504+
esc = _UNESCAPED_BRACKET_RE.sub(r"\\\1", text)
437505
prefix = "!" if image else ""
438506
if url is None:
439507
return f"{prefix}[{esc}]"
@@ -460,9 +528,12 @@ def _heading_prefix(element: _Element) -> str:
460528
return "#" * number
461529

462530

463-
def _image_markup(element: _Element) -> str:
531+
def _image_markup(element: _Element, include_formatting: bool = True) -> str:
464532
"Markdown image for a graphic element: ![alt](src)."
465533
alt = f"{element.get('title', '')} {element.get('alt', '')}".strip()
534+
# alt/title come from attributes, so the text/tail tree pass never reaches them
535+
if include_formatting:
536+
alt = _escape_inline_specials(alt)
466537
return _md_link(alt, element.get("src", ""), image=True)
467538

468539

@@ -471,7 +542,7 @@ def _collect_inline_text(element: _Element, include_formatting: bool) -> str:
471542
parts: list[str] = [element.text] if element.text else []
472543
for child in element:
473544
if child.tag == "graphic":
474-
parts.append(_image_markup(child))
545+
parts.append(_image_markup(child, include_formatting))
475546
elif child.tag == "lb":
476547
parts.append("\n")
477548
elif child.tag in INLINE_FORMATTABLE:
@@ -499,14 +570,19 @@ def replace_element_text(
499570
elem_text = _collect_inline_text(element, include_formatting)
500571
else:
501572
elem_text = element.text or ""
573+
# a fresh paragraph/list-item/quote line must not start with a marker that reads as block
574+
# syntax; table cells are inline-only in GFM, so this never applies there
575+
if include_formatting and elem_text and not in_cell and element.tag in ("p", "quote", "item"):
576+
elem_text = _escape_block_start(elem_text)
502577
# handle formatting: convert to markdown
503578
if include_formatting and elem_text:
504579
if element.tag in ("article", "list", "table"):
505580
elem_text = elem_text.strip()
506581
elif element.tag == "head" and not in_cell:
507582
elem_text = f"{_heading_prefix(element)} {elem_text}"
508583
elif element.tag == "del":
509-
elem_text = _md_wrap(elem_text.replace("~~", "~\\~"), "~~")
584+
# the tree-wide pass already escaped every '~' in elem_text, so no literal "~~" survives
585+
elem_text = _md_wrap(elem_text, "~~")
510586
elif element.tag == "hi":
511587
rend = element.get("rend") or ""
512588
marker = HI_FORMATTING.get(rend)
@@ -601,7 +677,7 @@ def process_element(
601677

602678
if not _renders_inline:
603679
if element.tag == "graphic":
604-
image = f"{_list_marker(element, in_item, include_formatting)}{_image_markup(element)}"
680+
image = f"{_list_marker(element, in_item, include_formatting)}{_image_markup(element, include_formatting)}"
605681
if in_cell:
606682
image = _escape_cell(image)
607683
returnlist.append(image)
@@ -662,9 +738,10 @@ def xmltotxt(xmloutput: _Element | None, include_formatting: bool) -> str:
662738
returnlist: list[str] = []
663739

664740
if include_formatting:
665-
# math rewrite, emphasis collapse, lb removal mutate the tree; protect caller's copy
741+
# math rewrite, special-char escaping, emphasis collapse, lb removal mutate the tree; protect caller's copy
666742
xmloutput = deepcopy(xmloutput)
667743
_convert_math_tree(xmloutput)
744+
_escape_inline_specials_tree(xmloutput)
668745
_collapse_emphasis(xmloutput)
669746
_merge_adjacent_hi(xmloutput)
670747
_strip_block_whitespace(xmloutput)

0 commit comments

Comments
 (0)