Skip to content

Fix normalize-space() collapsing NBSP (U+00A0) into a regular space - #2

Merged
leonelsanchesdasilva merged 3 commits into
DesignLiquido:mainfrom
nachocases:fix/normalize-space-nbsp
Aug 12, 2026
Merged

Fix normalize-space() collapsing NBSP (U+00A0) into a regular space#2
leonelsanchesdasilva merged 3 commits into
DesignLiquido:mainfrom
nachocases:fix/normalize-space-nbsp

Conversation

@nachocases

Copy link
Copy Markdown
Contributor

Summary

fn:normalize-space() collapses non-breaking spaces (U+00A0) into regular spaces (U+0020), and can also merge/strip them entirely when adjacent to other whitespace. Per the XPath/XQuery Functions & Operators spec, normalize-space should only treat #x9 (tab), #xA (LF), #xD (CR), and #x20 (space) as whitespace — U+00A0 is a distinct character and must be left untouched. This makes the output of any stylesheet that runs field values through normalize-space() (a very common pattern) silently lossy for any text containing NBSP.

This isn't a hypothetical: SAT (Mexico's tax authority) official CFDI "cadena original" stylesheets wrap nearly every field in a normalize-space() call, and real invoice text (addresses, product descriptions) legitimately contains NBSP. Since the cadena original is the exact byte string that gets digitally signed and later verified, this bug produces a string that doesn't match what a spec-compliant processor (e.g. Saxon) produces from the same input — i.e. this is a correctness bug, not a cosmetic one. (This is consumed downstream via the xslt-processor package, which vendors this repo as a submodule at src/xpath/lib.)

Root cause

Both implementations of normalize-space used JavaScript's native .trim() and the \s regex character class. Per ECMA-262, both of these treat U+00A0 as whitespace — that's a JS-specific definition, not the XML/XPath one.

  1. src/expressions/function-call-expression.ts — the BUILT_IN_FUNCTIONS['normalize-space'] entry (used for XPath 2.0/3.0 function references):
   'normalize-space': (_ctx, arg) => String(arg).trim().replace(/\s+/g, ' '),
  1. src/expressions/function-call-expression.ts — the normalizeSpace() private method (used by the XPath 1.0-compatible evaluate() switch statement):
   private normalizeSpace(args: XPathResult[], context: XPathContext): string {
       const str = args.length === 0 ? this.stringValue([], context) : this.convertToString(args[0]);
       return str.trim().replace(/\s+/g, ' ');
   }

Fix

Both are changed to use an XML-whitespace-only pattern ([\t\n\r ]) instead of .trim()/\s, matching the spec exactly:

String(arg)
    .replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, '')
    .replace(/[\t\n\r ]+/g, ' ')

This preserves all existing behavior for real XML whitespace (multiple runs still collapse to a single space, leading/trailing still trimmed) while leaving NBSP (and any other non-XML-whitespace character) completely untouched.

Verification

Manually verified against the built package (xslt-processor@5.0.11, which vendors this repo) with a minimal repro:

const NBSP = ' ';
const xml = `<root><value>Foo${NBSP}Bar</value></root>`;
// stylesheet: <xsl:value-of select="normalize-space(value)"/>
  • Before fix: output codepoints 46 6f 6f 20 42 61 72 (NBSP silently became U+0020)
  • After fix: output codepoints 46 6f 6f a0 42 61 72 (NBSP preserved, matching plain xsl:value-of with no normalize-space())

Tests

Added three regression tests to tests/expressions/expressions.test.ts, next to the existing normalize-space tests:

  • NBSP preserved when there's no surrounding XML whitespace to trim
  • Surrounding XML whitespace is still trimmed while an internal NBSP run is preserved untouched
  • NBSP preserved when normalize-space() is called with no argument (using node textContent)

Happy to adjust the approach (e.g. extracting a shared whitespace-trim helper) if you'd prefer a different structure.


Note: drafted with Claude's assistance (root-cause investigation, patch, and tests); verified manually against xslt-processor@5.0.11 before submitting.

normalize-space() used JavaScript's native .trim()/\s, which (unlike
the XPath/XQuery F&O spec) treats U+00A0 as whitespace. This silently
corrupted any NBSP in the source text into a plain space wherever
normalize-space() was called - including in real-world stylesheets
like SAT's CFDI "cadena original" templates, which wrap nearly every
field in normalize-space() and can legitimately contain NBSP in text.

Per the spec, normalize-space() must only treat #x9 (tab), #xA (LF),
#xD (CR) and #x20 (space) as whitespace. This fixes both call sites
that implement it (the BUILT_IN_FUNCTIONS map used for XPath 2.0/3.0
function references, and the normalizeSpace() method used by the
XPath 1.0-compatible evaluate() switch) to use an XML-whitespace-only
pattern instead of .trim()/\s, and adds regression tests covering
NBSP preservation with and without surrounding XML whitespace.
normalize-space() used JavaScript's native .trim()/\s, which (unlike
the XPath/XQuery F&O spec) treats U+00A0 as whitespace. This silently
corrupted any NBSP in the source text into a plain space wherever
normalize-space() was called. Per the spec, normalize-space() must
only treat #x9 (tab), #xA (LF), #xD (CR) and #x20 (space) as
whitespace. Fixes both call sites that implement it (the
BUILT_IN_FUNCTIONS map used for XPath 2.0/3.0 function references,
and the normalizeSpace() method used by the XPath 1.0-compatible
evaluate() switch) to use an XML-whitespace-only pattern instead of
.trim()/\s.
Covers: NBSP inside a string with no surrounding XML whitespace,
NBSP runs with surrounding XML whitespace to trim, and NBSP in
node text when normalize-space() is called with no argument.

@leonelsanchesdasilva leonelsanchesdasilva left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@leonelsanchesdasilva
leonelsanchesdasilva merged commit 065d8b3 into DesignLiquido:main Aug 12, 2026
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.

2 participants