Skip to content

Merge entity-split text nodes after parsing - #3

Open
gthb wants to merge 1 commit into
flother:mainfrom
gthb:fix-entity-split-text-nodes
Open

Merge entity-split text nodes after parsing#3
gthb wants to merge 1 commit into
flother:mainfrom
gthb:fix-entity-split-text-nodes

Conversation

@gthb

@gthb gthb commented Jul 7, 2026

Copy link
Copy Markdown

What

Merge runs of adjacent sibling text nodes after parsing each XML part, so text()-based XPath predicates evaluate correctly, in reasonable time, on entity-bearing content.

Why

sxd-document creates a separate text node per character-data token, so entity and character references and CDATA sections split the surrounding text into adjacent sibling text nodes: IF(C9>0,OFFSET(…)) parses as several text nodes. That violates the XPath 1.0 data model (§5.7: "a text node never has an immediately following or preceding sibling that is a text node") and breaks contains(text(), "…") twice over:

  • only the first fragment is tested, so contains misses matches where the needle follows an entity reference (common in spreadsheet formulas)
  • string-converting a multi-node nodeset makes sxd-xpath rebuild a document-order index of the entire document per predicate evaluation (sxd-xpath#121).

A contains(text(),"OFFSET(") scan of a ~3,050-workbook corpus ran 10.5 hours without completing (one 40 MB sheet with ~35k entity-bearing formulas takes over 6 minutes by itself). With this fix the corpus completes in 1m34s, and finds matches which the old behaviour missed.

How

normalize_text_nodes() performs the standard DOM normalize() operation once per parsed document, merging each run of adjacent sibling text nodes. With maximal text runs restored, text() nodesets are single-node — the correct data model and sxd-xpath's fast path.

Out of scope

A genuinely multi-node nodeset being string-converted (e.g. contains(x:v, "…") over several v children) still rebuilds the index per evaluation. That's left to (sxd-path#121), no workaround attempted here.

Note

I contributed a fix for the root cause upstream in sxd-document#101 (joining character data during parsing). If that is accepted and released, this workaround becomes redundant and can be dropped along with the dependency bump.

sxd-document emits a separate text node per character-data token, so
every entity reference splits the surrounding text into sibling text
nodes (A&B parses as three). That violates the XPath 1.0 data model
(maximal text runs) and broke text()-based queries in two ways:

- contains(text(), "X") silently tested only the first fragment,
  missing content that follows an entity reference;

- string-converting a multi-node nodeset sends sxd-xpath through
  Nodeset::document_order_first, which rebuilds a document-order index
  of the entire document per predicate evaluation. Over a 40MB
  worksheet with ~35k entity-bearing formulas this is effectively
  unbounded: a corpus query that completes in 1m34s with this fix
  previously ran 10.5 hours without finishing (and one worker reached
  7GB RSS).

Normalize each parsed document once (the standard DOM normalize()
operation) before evaluation, restoring maximal text runs so text()
nodesets are single-node and stay on sxd-xpath's fast path.

Known residual: genuinely distinct multi-element nodesets (e.g.
contains(x:v, ...) with several v children) still hit the slow sxd
path; that needs an upstream sxd-xpath fix (caching DocOrder).
@gthb

gthb commented Aug 11, 2026

Copy link
Copy Markdown
Author

I contributed a fix for the root cause upstream in sxd-document#101 (joining character data during parsing). If that is accepted and released [...]

Considering that project's activity we should perhaps not hold our breath 😊

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant