Skip to content

DOMParser drops whitespace-only text nodes between inline elements #333

Description

@Juanquintana-tech

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 &nbsp; 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions