From f1bc46e99b57798c4e3220d8c2d2c753e037fca0 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 24 Jun 2026 03:14:01 +0200 Subject: [PATCH] fix(emit): split ?> in processing-instruction data with a space (#262) PI data may not contain ?>, which would terminate the instruction early. The serializer emitted it raw, producing malformed output. Insert a space between an offending ? and > (XSLT 1.0 7.3 recovery: a processor recovers from a generated ?> by inserting a space after the ?), via a shared Escaping helper applied at both XML PI serialization sites. A trailing ? is left alone. Closes Apache Xalan conformance case output72. --- CHANGELOG.md | 2 ++ Sources/Emitting/Escaping.swift | 14 ++++++++++++++ Sources/Emitting/Serializer.swift | 2 +- Sources/Emitting/Writer.swift | 2 +- Tests/Fixtures/xalan-baseline.txt | 1 - Tests/SerializerTests.swift | 8 ++++++++ 6 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f571d6..070475f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Serializing a processing instruction whose data contains `?>` now inserts a space (`? >`) so the instruction is not terminated early (XSLT 1.0 7.3: a processor recovers from a generated `?>` by inserting a space after the `?`). The serializer emitted the `?>` raw, producing a malformed instruction. Closes Apache Xalan conformance case `output72`. + - The XSLT conformance harness now decodes source and stylesheet files through the library's own encoding detection (a BOM or the XML declaration) rather than assuming UTF-8, so a non-UTF-8 corpus input is fed to the transform instead of refused. This confirms the library round-trips a UTF-16 source correctly and closes Apache Xalan conformance cases `output80` (UTF-16) and `attribvaltemplate08`, which were unreadable by the harness, not library failures. - The `html` output method now serializes an element whose expanded-name has a non-null namespace URI as XML rather than HTML (XSLT 1.0 16.2). When the result's document element is namespaced, the tree is serialized through the XML path (empty elements self-close, the namespace is declared once and inherited) while the html method still omits the XML declaration; previously such a tree was emitted as HTML, leaving empty namespaced elements unclosed and repeating the `xmlns` declaration on every element, which is not well-formed XML. A null-namespace tree is unchanged. The rule is applied at document-element granularity rather than per element, so a tree mixing null- and non-null-namespace elements follows its document element's path (the realistic result is all-null or all-namespaced). (Apache Xalan `output63` exercises this rule but remains baselined: its gold differs further only in attribute order, which is not normative.) diff --git a/Sources/Emitting/Escaping.swift b/Sources/Emitting/Escaping.swift index 60d0b9f..b31a566 100644 --- a/Sources/Emitting/Escaping.swift +++ b/Sources/Emitting/Escaping.swift @@ -64,5 +64,19 @@ extension PureXML.Emitting { if previousHyphen { result.append(" ") } return result } + + /// Makes `data` safe inside a processing instruction: PI data may not + /// contain `?>`, so a space is inserted between an offending `?` and `>` + /// (the XSLT 1.0 7.3 recovery, so `a?>b` becomes `a? >b`). + static func processingInstruction(_ data: String) -> String { + var result = "" + var previousQuestion = false + for character in data { + if character == ">", previousQuestion { result.append(" ") } + result.append(character) + previousQuestion = character == "?" + } + return result + } } } diff --git a/Sources/Emitting/Serializer.swift b/Sources/Emitting/Serializer.swift index 0ca841a..07e8674 100644 --- a/Sources/Emitting/Serializer.swift +++ b/Sources/Emitting/Serializer.swift @@ -87,7 +87,7 @@ public extension PureXML.Emitting { case let .comment(value): output += "" case let .processingInstruction(target, data): - output += data.isEmpty ? "" : "" + output += data.isEmpty ? "" : "" } } diff --git a/Sources/Emitting/Writer.swift b/Sources/Emitting/Writer.swift index 534e1b5..84f91ae 100644 --- a/Sources/Emitting/Writer.swift +++ b/Sources/Emitting/Writer.swift @@ -109,7 +109,7 @@ public extension PureXML.Emitting { if options.prettyPrint, !open.isEmpty { output += options.lineEnding + pad(open.count) } - output += data.isEmpty ? "" : "" + output += data.isEmpty ? "" : "" markParentHasElements() } diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index ad4f890..c6f5139 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -30,7 +30,6 @@ output/output26 output/output32 output/output63 output/output70 -output/output72 output/output73 output/output75 output/output86 diff --git a/Tests/SerializerTests.swift b/Tests/SerializerTests.swift index 0fca65c..02d3067 100644 --- a/Tests/SerializerTests.swift +++ b/Tests/SerializerTests.swift @@ -9,6 +9,14 @@ struct SerializerTests { #expect(PureXML.serialize(node, options: .compact) == "
") } + @Test("A ?> inside processing-instruction data is split by a space") + func test_processingInstructionEscapesTerminator() { + // PI data may not contain ?>; the XSLT 1.0 7.3 recovery inserts a space + // (Apache Xalan output72). A trailing ? is safe and is left alone. + let instruction = PureXML.Model.Node.processingInstruction(target: "p", data: "a ?> b ?") + #expect(PureXML.serialize(instruction, options: .compact) == " b ??>") + } + @Test("Attributes and text are escaped") func test_escapesAttributesAndText() { let element = PureXML.Model.Element(