From 4db0657b9c873cba2cd1732c771ce252b0d55a49 Mon Sep 17 00:00:00 2001 From: rajanpanth Date: Thu, 6 Aug 2026 20:03:42 +0545 Subject: [PATCH] fix: preserve negative zero sign when stringifying values Every raw-value-to-string conversion in the builder (String()/toString()/ implicit ToString via concatenation) silently drops the sign of -0, corrupting it to 0 for both text and attribute values, in both preserveOrder modes, and in stopNode raw-content paths. XML has no separate int/float syntax, so this loss is silent and easy to miss on a parse -> build round trip. Add a single shared valToStr() helper that special-cases Object.is(val, -0) and use it at every such conversion site instead of the ad hoc String(val)/''+val/val.toString() calls. --- spec/j2x_ordered_spec.js | 12 ++++++++++++ spec/j2x_spec.js | 19 +++++++++++++++++++ spec/stopNodes_spec.js | 24 ++++++++++++++++++++++++ src/fxb.js | 19 ++++++++++++------- src/orderedJs2Xml.js | 9 +++++---- src/util.js | 13 ++++++++++--- 6 files changed, 82 insertions(+), 14 deletions(-) diff --git a/spec/j2x_ordered_spec.js b/spec/j2x_ordered_spec.js index 1463f83..61a0a5e 100644 --- a/spec/j2x_ordered_spec.js +++ b/spec/j2x_ordered_spec.js @@ -386,6 +386,18 @@ describe("XMLBuilder", function () { expect(output).toContain('

Nested

'); }); + it("should preserve the sign of a negative zero text value", function () { + const builder = new XMLBuilder({ preserveOrder: true }); + const result = builder.build([{ a: [{ "#text": -0 }] }]); + expect(result).toEqual(`-0`); + }); + + it("should preserve the sign of a negative zero attribute value", function () { + const builder = new XMLBuilder({ ignoreAttributes: false, preserveOrder: true }); + const result = builder.build([{ a: [{ "#text": "v" }], ":@": { "@_x": -0 } }]); + expect(result).toEqual(`v`); + }); + }); describe("XMLBuilder- array processing issue", function () { diff --git a/spec/j2x_spec.js b/spec/j2x_spec.js index 83dfc72..3e0e1f6 100644 --- a/spec/j2x_spec.js +++ b/spec/j2x_spec.js @@ -688,4 +688,23 @@ describe("XMLBuilder", function () { expect(result).toEqual(expected); }); + it("should preserve the sign of a negative zero tag value", function () { + const builder = new XMLBuilder(); + const result = builder.build({ a: -0 }); + expect(result).toEqual(`-0`); + }); + + it("should preserve the sign of a negative zero attribute value", function () { + const builder = new XMLBuilder({ ignoreAttributes: false }); + const result = builder.build({ a: { "@_x": -0 } }); + expect(result).toEqual(``); + }); + + it("should preserve the sign of a negative zero value with oneListGroup", function () { + const jObj = { a: [-0, -0] }; + const builder = new XMLBuilder({ oneListGroup: "true" }); + const result = builder.build(jObj); + expect(result).toEqual(`-0-0`); + }); + }); diff --git a/spec/stopNodes_spec.js b/spec/stopNodes_spec.js index 5e53395..7e09c28 100644 --- a/spec/stopNodes_spec.js +++ b/spec/stopNodes_spec.js @@ -192,6 +192,18 @@ describe("stopNodes Builder - Basic Tests", function () { expect(output).toContain("str"); }); + it("should preserve the sign of a negative zero value in raw stopNode content", function () { + const options = { + stopNodes: ["a"], + preserveOrder: false + }; + + const builder = new XMLBuilder(options); + const output = builder.build({ a: -0 }); + + expect(output).toEqual("-0"); + }); + }); describe("preserveOrder: true", function () { @@ -302,6 +314,18 @@ describe("stopNodes Builder - Basic Tests", function () { expect(output).toContain("
test > < &
"); }); + it("should preserve the sign of a negative zero value in raw stopNode content", function () { + const options = { + stopNodes: ["a"], + preserveOrder: true + }; + + const builder = new XMLBuilder(options); + const output = builder.build([{ a: [{ "#text": -0 }] }]); + + expect(output).toEqual("-0"); + }); + }); }); \ No newline at end of file diff --git a/src/fxb.js b/src/fxb.js index 73c61e6..f887b14 100644 --- a/src/fxb.js +++ b/src/fxb.js @@ -3,7 +3,7 @@ import buildFromOrderedJs from './orderedJs2Xml.js'; import getIgnoreAttributesFn from "./ignoreAttributes.js"; import { Expression, Matcher } from 'path-expression-matcher'; -import { safeComment, safeCdata, escapeAttribute } from './util.js'; +import { safeComment, safeCdata, escapeAttribute, valToStr } from './util.js'; import { createValidator } from 'xml-naming'; const defaultOptions = { @@ -209,11 +209,11 @@ Builder.prototype.j2x = function (jObj, level, matcher, qNameValidator) { if (attr && !this.ignoreAttributesFn(attr, jPath)) { // Resolve the attribute name through sanitizeName const resolvedAttr = resolveTagName(attr, true, this.options, matcher, qNameValidator); - attrStr += this.buildAttrPairStr(resolvedAttr, '' + jObj[key], isCurrentStopNode); + attrStr += this.buildAttrPairStr(resolvedAttr, valToStr(jObj[key]), isCurrentStopNode); } else if (!attr) { //tag value if (key === this.options.textNodeName) { - let newval = this.options.tagValueProcessor(key, '' + jObj[key]); + let newval = this.options.tagValueProcessor(key, valToStr(jObj[key])); val += this.replaceEntitiesValue(newval); } else { // Check if this is a stopNode before building @@ -223,7 +223,7 @@ Builder.prototype.j2x = function (jObj, level, matcher, qNameValidator) { if (isStopNode) { // Build as raw content without encoding - const textValue = '' + jObj[key]; + const textValue = valToStr(jObj[key]); if (textValue === '') { val += this.indentate(level) + '<' + resolvedKey + this.closeTag(resolvedKey) + this.tagEndChar; } else { @@ -265,6 +265,7 @@ Builder.prototype.j2x = function (jObj, level, matcher, qNameValidator) { if (this.options.oneListGroup) { let textValue = this.options.tagValueProcessor(resolvedKey, item); textValue = this.replaceEntitiesValue(textValue); + textValue = valToStr(textValue); listTagVal += textValue; } else { // Check if this is a stopNode before building @@ -274,7 +275,7 @@ Builder.prototype.j2x = function (jObj, level, matcher, qNameValidator) { if (isStopNode) { // Build as raw content without encoding - const textValue = '' + item; + const textValue = valToStr(item); if (textValue === '') { listTagVal += this.indentate(level) + '<' + resolvedKey + this.closeTag(resolvedKey) + this.tagEndChar; } else { @@ -298,7 +299,7 @@ Builder.prototype.j2x = function (jObj, level, matcher, qNameValidator) { for (let j = 0; j < L; j++) { // Resolve attribute names inside attributesGroupName const resolvedAttr = resolveTagName(Ks[j], true, this.options, matcher, qNameValidator); - attrStr += this.buildAttrPairStr(resolvedAttr, '' + jObj[key][Ks[j]], isCurrentStopNode); + attrStr += this.buildAttrPairStr(resolvedAttr, valToStr(jObj[key][Ks[j]]), isCurrentStopNode); } } else { val += this.processTextOrObjNode(jObj[key], resolvedKey, level, matcher, qNameValidator) @@ -310,7 +311,7 @@ Builder.prototype.j2x = function (jObj, level, matcher, qNameValidator) { Builder.prototype.buildAttrPairStr = function (attrName, val, isStopNode) { if (!isStopNode) { - val = this.options.attributeValueProcessor(attrName, '' + val); + val = this.options.attributeValueProcessor(attrName, valToStr(val)); val = this.replaceEntitiesValue(val); } if (this.options.suppressBooleanAttributes && val === "true") { @@ -570,6 +571,10 @@ Builder.prototype.buildTextValNode = function (val, key, attrStr, level, matcher // Normal processing: apply tagValueProcessor and entity replacement let textValue = this.options.tagValueProcessor(key, val); textValue = this.replaceEntitiesValue(textValue); + // tagValueProcessor may return the raw value unchanged (default is identity), and + // replaceEntitiesValue no-ops on non-strings, so a plain number can still reach here; + // stringify it now, sign-preserving, before it's implicitly ToString'd below. + textValue = valToStr(textValue); if (textValue === '') { return this.indentate(level) + '<' + key + attrStr + this.closeTag(key) + this.tagEndChar; diff --git a/src/orderedJs2Xml.js b/src/orderedJs2Xml.js index 965956e..9d3ff1e 100644 --- a/src/orderedJs2Xml.js +++ b/src/orderedJs2Xml.js @@ -1,5 +1,5 @@ import { Expression, Matcher } from 'path-expression-matcher'; -import { safeComment, safeCdata, escapeAttribute } from "./util.js"; +import { safeComment, safeCdata, escapeAttribute, valToStr } from "./util.js"; import { createValidator } from 'xml-naming'; const EOL = "\n"; @@ -88,7 +88,7 @@ function arrToStr(arr, options, indentation, matcher, stopNodeExpressions, qName if (!Array.isArray(arr)) { // Non-array values (e.g. string tag values) should be treated as text content if (arr !== undefined && arr !== null) { - let text = arr.toString(); + let text = valToStr(arr); text = replaceEntitiesValue(text, options); return text; } @@ -127,6 +127,7 @@ function arrToStr(arr, options, indentation, matcher, stopNodeExpressions, qName tagText = options.tagValueProcessor(tagName, tagText); tagText = replaceEntitiesValue(tagText, options); } + tagText = valToStr(tagText); if (isPreviousElementTag) { xmlStr += indentation; } @@ -235,7 +236,7 @@ function getRawContent(arr, options) { if (!Array.isArray(arr)) { // Non-array values return as-is if (arr !== undefined && arr !== null) { - return arr.toString(); + return valToStr(arr); } return ""; } @@ -247,7 +248,7 @@ function getRawContent(arr, options) { if (tagName === options.textNodeName) { // Raw text content - NO processing, NO entity replacement - content += item[tagName]; + content += valToStr(item[tagName]); } else if (tagName === options.cdataPropName) { // CDATA content content += item[tagName][0][options.textNodeName]; diff --git a/src/util.js b/src/util.js index 01e2704..d584b13 100644 --- a/src/util.js +++ b/src/util.js @@ -1,16 +1,23 @@ +// String(val)/val.toString() drop the sign of -0 (e.g. String(-0) === '0'), silently +// corrupting a round-tripped negative-zero value. XML has no separate int/float syntax, +// so this is the single place every raw value gets turned into text. +export function valToStr(val) { + return typeof val === 'number' && Object.is(val, -0) ? '-0' : String(val) +} + export function safeComment(val) { - return String(val) + return valToStr(val) .replace(/--/g, '- -') // -- is illegal anywhere in comment content .replace(/--/g, '- -') // handle the scenario when 2 consiucative dashes appears .replace(/-$/, '- '); // trailing - would form -- with the closing --> } export function safeCdata(val) { - return String(val).replace(/\]\]>/g, ']]]]>') + return valToStr(val).replace(/\]\]>/g, ']]]]>') } export function escapeAttribute(val) { - return String(val).replace(/"/g, '"').replace(/'/g, ''') + return valToStr(val).replace(/"/g, '"').replace(/'/g, ''') } \ No newline at end of file