From 74e54b05afbcd7b4844c997750841d99df6239db Mon Sep 17 00:00:00 2001 From: Sskift <163287349+Sskift@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:39:51 +0800 Subject: [PATCH] fix: preserve original metadata offsets for CRLF input --- spec/endIndex_spec.js | 56 +++++++++++++++++++++++++++++-- src/xmlparser/OrderedObjParser.js | 39 +++++++++++++++------ 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/spec/endIndex_spec.js b/spec/endIndex_spec.js index 4f14eff8..10170ec3 100644 --- a/spec/endIndex_spec.js +++ b/spec/endIndex_spec.js @@ -19,8 +19,8 @@ function collectMeta(node, out = []) { } /** Parse xml with metadata capture on and return a Map of rawTagName -> raw text span. */ -function parseSpans(xml) { - const parser = new XMLParser({ preserveOrder: true, ignoreAttributes: false, captureMetaData: true }); +function parseSpans(xml, options = {}) { + const parser = new XMLParser(Object.assign({ preserveOrder: true, ignoreAttributes: false, captureMetaData: true }, options)); const result = parser.parse(xml); return new Map(collectMeta(result).map(([tag, m]) => [tag, xml.slice(m.startIndex, m.endIndex)])); } @@ -94,4 +94,56 @@ describe("XMLParser captureMetaData endIndex", function () { expect(byTag.get("b")).toBe(`x`); expect(byTag.get("a")).toBe(xml); }); + + for (const newline of ["\n", "\r", "\r\n", "\r\n\n\r"]) { + it("uses original input offsets with " + JSON.stringify(newline) + " line endings", function () { + const declaration = ``; + const child = `first${newline}second`; + const empty = ``; + const pi = ``; + const root = `${child}${empty}${pi}`; + const spans = parseSpans(declaration + newline + root); + + expect(spans.get("?xml")).toBe(declaration); + expect(spans.get("root")).toBe(root); + expect(spans.get("child")).toBe(child); + expect(spans.get("empty")).toBe(empty); + expect(spans.get("?pi")).toBe(pi); + }); + } + + it("uses original offsets for stop nodes, unpaired tags and retained siblings", function () { + const stop = "first\r\nraw\r\nlast"; + const br = ''; + const xml = `\r\n${stop}\r\n${br}\r\n\r\n`; + const spans = parseSpans(xml, { + stopNodes: ["root.stop"], + unpairedTags: ["br"], + updateTag: (tagName) => tagName === "skip" ? false : tagName, + }); + + expect(spans.get("root")).toBe(xml); + expect(spans.get("stop")).toBe(stop); + expect(spans.get("br")).toBe(br); + expect(spans.has("skip")).toBeFalse(); + }); + + for (const inputType of ["string", "Buffer", "Uint8Array"]) { + it("keeps original offsets and normalizes values for " + inputType + " input", function () { + const xml = '\r\nfirst\r\nsecond\r\n'; + const input = inputType === "string" ? xml + : inputType === "Buffer" ? Buffer.from(xml) : new TextEncoder().encode(xml); + const result = new XMLParser({ + ignoreAttributes: false, + captureMetaData: true, + alwaysCreateTextNode: true, + }).parse(input); + const metadata = result.root.child[XML_METADATA]; + + expect(xml.slice(metadata.startIndex, metadata.endIndex)) + .toBe('first\r\nsecond'); + expect(result.root.child["@_attr"]).toBe("a\nb"); + expect(result.root.child["#text"]).toBe("first\nsecond"); + }); + } }); diff --git a/src/xmlparser/OrderedObjParser.js b/src/xmlparser/OrderedObjParser.js index 58c6a5f2..e0cb3bd5 100644 --- a/src/xmlparser/OrderedObjParser.js +++ b/src/xmlparser/OrderedObjParser.js @@ -283,7 +283,15 @@ function buildAttributesMap(attrStr, jPath, tagName, force = false) { } } const parseXml = function (xmlData) { - xmlData = xmlData.replace(/\r\n?/g, "\n"); //TODO: remove this line + const options = this.options; + const crlfPositions = options.captureMetaData ? [] : undefined; + xmlData = xmlData.replace(/\r\n?/g, options.captureMetaData ? (newline, index) => { + if (newline.length === 2) { + // Store the position after earlier CRLF pairs have been normalized. + crlfPositions.push(index - crlfPositions.length); + } + return "\n"; + } : "\n"); const xmlObj = new xmlNode('!xml'); let currentNode = xmlObj; let textData = ""; @@ -296,7 +304,6 @@ const parseXml = function (xmlData) { this.entityExpansionCount = 0; this.currentExpandedLength = 0; this.doctypefound = false; - const options = this.options; const docTypeReader = new DocTypeReader(options.processEntities); const xmlLen = xmlData.length; for (let i = 0; i < xmlLen; i++) {//for each char in XML data @@ -340,7 +347,7 @@ const parseXml = function (xmlData) { currentNode = this.tagsNodeStack.pop() || xmlObj;//avoid recursion, set the parent tag scope if (options.captureMetaData && currentNode) { - currentNode.addEndIndex(closeIndex + 1); + currentNode.addEndIndex(getOriginalIndex(closeIndex + 1, crlfPositions)); } textData = ""; i = closeIndex; @@ -366,11 +373,12 @@ const parseXml = function (xmlData) { if (tagData.tagName !== tagData.tagExp && tagData.attrExpPresent && options.ignoreAttributes !== true) { childNode[":@"] = attsMap } - this.addChild(currentNode, childNode, this.readonlyMatcher, i); + const startIndex = options.captureMetaData ? getOriginalIndex(i, crlfPositions) : i; + this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex); if (options.captureMetaData) { // closeIndex points at '?' of the closing '?>' - currentNode.addEndIndex(tagData.closeIndex + 2); + currentNode.addEndIndex(getOriginalIndex(tagData.closeIndex + 2, crlfPositions)); } } @@ -502,7 +510,7 @@ const parseXml = function (xmlData) { this.isCurrentNodeStopNode = this.isItStopNode(); } - const startIndex = i; + const startIndex = options.captureMetaData ? getOriginalIndex(i, crlfPositions) : i; if (this.isCurrentNodeStopNode) { let tagContent = ""; @@ -538,7 +546,7 @@ const parseXml = function (xmlData) { this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex); if (options.captureMetaData) { - currentNode.addEndIndex(i + 1); + currentNode.addEndIndex(getOriginalIndex(i + 1, crlfPositions)); } } else { //selfClosing tag @@ -552,7 +560,7 @@ const parseXml = function (xmlData) { this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex); if (options.captureMetaData) { - currentNode.addEndIndex(closeIndex + 1); + currentNode.addEndIndex(getOriginalIndex(closeIndex + 1, crlfPositions)); } this.matcher.pop(); // Pop self-closing tag this.isCurrentNodeStopNode = false; // Reset flag @@ -565,7 +573,7 @@ const parseXml = function (xmlData) { this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex); if (options.captureMetaData) { - currentNode.addEndIndex(result.closeIndex + 1); + currentNode.addEndIndex(getOriginalIndex(result.closeIndex + 1, crlfPositions)); } this.matcher.pop(); // Pop unpaired tag this.isCurrentNodeStopNode = false; // Reset flag @@ -598,6 +606,17 @@ const parseXml = function (xmlData) { return xmlObj.child; } +function getOriginalIndex(index, crlfPositions) { + let low = 0; + let high = crlfPositions.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (crlfPositions[middle] < index) low = middle + 1; + else high = middle; + } + return index + low; +} + function addChild(currentNode, childNode, matcher, startIndex) { // unset startIndex if not requested if (!this.options.captureMetaData) startIndex = undefined; @@ -868,4 +887,4 @@ function sanitizeName(name, options) { return options.onDangerousProperty(name); } return name; -} \ No newline at end of file +}