Summary
DOMParser (and therefore from_html) drops a text node that consists only of whitespace when it sits between two adjacent inline elements. Words end up concatenated.
Reproduction
from prosemirror.model import Schema
from prosemirror.model.from_dom import from_html
schema = Schema({
"nodes": {
"doc": {"content": "block+"},
"paragraph": {"content": "inline*", "group": "block",
"parseDOM": [{"tag": "p"}], "toDOM": lambda n: ["p", 0]},
"text": {"group": "inline"},
},
"marks": {
"strong": {"parseDOM": [{"tag": "strong"}],
"toDOM": lambda m, inline: ["strong", 0]},
},
})
doc = from_html(schema, "<p><strong>bold</strong> <strong>text</strong></p>")
print([n.get("text") for n in doc["content"][0]["content"]])
Observed (0.6.1): ['boldtext']
Expected: ['bold', ' ', 'text']
Scope
The defect is narrow, which is what makes it easy to miss:
| Input |
Result |
<p>one <strong>two</strong> three four</p> |
['one ', 'two', ' three four'] — correct, tails with content survive |
<p>a<strong>b</strong>c</p> |
['a', 'b', 'c'] — correct |
<p><strong>a</strong> <strong>b</strong></p> |
['ab'] — space lost |
<p><strong>a </strong><strong>b</strong></p> |
['a b'] — survives when the space is inside the mark |
Not affected by ParseOptions(preserve_whitespace=...) in any of its three values (None, True, "full"), nor by using or a literal U+00A0 (both are matched by str.strip()).
Root cause
DOMParser._wrap_lxml_text (prosemirror/model/from_dom.py, around L144) synthesises the <lxmltext> pseudo-elements, but guards on the truthiness of .strip():
if d.text is not None and d.text.strip() and str(d.tag).lower() != "lxmltext":
...
if d.tail is not None and d.tail.strip():
...
A whitespace-only text/tail is therefore never wrapped, so add_text_node never sees it.
That matters because add_text_node already implements the upstream decision about whitespace-only nodes, faithfully translated from prosemirror-model:
if (
preserve_ws == "full"
or top.inline_context(dom_)
or re.search(r"[^ \t\r\n]", value) is not None
):
The third clause is the only one reachable for whitespace-only values, and it is false for them. The first two clauses exist precisely to preserve whitespace-only nodes: top.inline_context(dom_) is the branch that keeps a significant single space inside inline content (compare prosemirror-model 1.25.4, addTextNode, which has the same condition). The .strip() guard makes both unreachable.
Suggested fix
Guard on emptiness rather than on whitespace, and let add_text_node decide:
if d.text is not None and d.text != "" and str(d.tag).lower() != "lxmltext":
...
if d.tail is not None and d.tail != "":
...
Whitespace-only nodes in non-inline contexts still get dropped, because they fail all three clauses of the condition above, so the guard is redundant as well as harmful.
I tried this locally against a corpus of 15 documents exercising headings, inline marks, nested lists, blockquotes, code blocks, tables (including colspan/rowspan and per-column alignment), images with dimensions, and paragraph alignment. Round-trip fidelity (doc -> HTML -> doc) went from 14/15 to 15/15, with no other differences.
Happy to open a PR with the change plus a regression test if the approach looks right to you.
Environment: prosemirror-py 0.6.1, Python 3.14.7, lxml 6.1.1.
Summary
DOMParser(and thereforefrom_html) drops a text node that consists only of whitespace when it sits between two adjacent inline elements. Words end up concatenated.Reproduction
Observed (0.6.1):
['boldtext']Expected:
['bold', ' ', 'text']Scope
The defect is narrow, which is what makes it easy to miss:
<p>one <strong>two</strong> three four</p>['one ', 'two', ' three four']— correct, tails with content survive<p>a<strong>b</strong>c</p>['a', 'b', 'c']— correct<p><strong>a</strong> <strong>b</strong></p>['ab']— space lost<p><strong>a </strong><strong>b</strong></p>['a b']— survives when the space is inside the markNot affected by
ParseOptions(preserve_whitespace=...)in any of its three values (None,True,"full"), nor by using or a literal U+00A0 (both are matched bystr.strip()).Root cause
DOMParser._wrap_lxml_text(prosemirror/model/from_dom.py, around L144) synthesises the<lxmltext>pseudo-elements, but guards on the truthiness of.strip():A whitespace-only
text/tailis therefore never wrapped, soadd_text_nodenever sees it.That matters because
add_text_nodealready implements the upstream decision about whitespace-only nodes, faithfully translated fromprosemirror-model:The third clause is the only one reachable for whitespace-only values, and it is false for them. The first two clauses exist precisely to preserve whitespace-only nodes:
top.inline_context(dom_)is the branch that keeps a significant single space inside inline content (compareprosemirror-model1.25.4,addTextNode, which has the same condition). The.strip()guard makes both unreachable.Suggested fix
Guard on emptiness rather than on whitespace, and let
add_text_nodedecide:Whitespace-only nodes in non-inline contexts still get dropped, because they fail all three clauses of the condition above, so the guard is redundant as well as harmful.
I tried this locally against a corpus of 15 documents exercising headings, inline marks, nested lists, blockquotes, code blocks, tables (including
colspan/rowspanand per-column alignment), images with dimensions, and paragraph alignment. Round-trip fidelity (doc -> HTML -> doc) went from 14/15 to 15/15, with no other differences.Happy to open a PR with the change plus a regression test if the approach looks right to you.
Environment: prosemirror-py 0.6.1, Python 3.14.7, lxml 6.1.1.