From 74bf622275a21bbb9e6e8e296b77ff607018c964 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 24 Jun 2026 03:27:49 +0200 Subject: [PATCH] fix(html): percent-escape non-ASCII characters in URI-valued attributes (closes output32) (#263) * fix(html): percent-escape non-ASCII characters in URI-valued attributes XSLT 1.0 16.2 / HTML 4.01 B.2.1: the html output method percent-escapes non-ASCII and control characters in attributes of type %URI (href, src, cite, action, and the rest), writing each as the uppercase hex of its UTF-8 bytes, while keeping " and & as entities. Other attributes are unaffected. The serializer left such characters unescaped. Grounded in the libxml2 htmlSerializeUri study; reimplemented in pure Swift, no Foundation. Closes Apache Xalan conformance case output32. * docs(html): disclose URI-attribute escaping round-trip normalization Address the URI-escaping critics: the html serializer follows libxml2's HTML output model where URI-valued attributes are always percent-escaped, so a parse-then-serialize round-trip normalizes a space or non-ASCII byte to %HH. Document that this is idempotent (a literal % is left as is) and that every space is escaped (leading ones included, where libxml2 skips them). Add an idempotency test. --- CHANGELOG.md | 2 ++ Sources/HTML/HTMLSerializer.swift | 36 ++++++++++++++++++++++++++++++- Sources/HTML/HTMLToken.swift | 8 +++++++ Tests/Fixtures/xalan-baseline.txt | 1 - Tests/XSLTHTMLOutputTests.swift | 20 +++++++++++++++++ 5 files changed, 65 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 070475f..51b744c 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 +- The `html` output method now percent-escapes non-ASCII and control characters in URI-valued attributes (XSLT 1.0 16.2, HTML 4.01 appendix B.2.1). For the HTML 4 attributes of type %URI (`href`, `src`, `cite`, `action`, and the rest), each non-ASCII or control/space character in the value is written as the uppercase hex of its UTF-8 bytes (so `cite="bë.xml"` becomes `cite="b%C3%AB.xml"`), while `"` and `&` keep their entity form; other attributes are unaffected. Closes Apache Xalan conformance case `output32`. + - 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. diff --git a/Sources/HTML/HTMLSerializer.swift b/Sources/HTML/HTMLSerializer.swift index 4d2849d..7109dff 100644 --- a/Sources/HTML/HTMLSerializer.swift +++ b/Sources/HTML/HTMLSerializer.swift @@ -55,7 +55,41 @@ extension PureXML.HTML { if Elements.booleanAttributes.contains(name.lowercased()), attribute.value.lowercased() == name.lowercased() { return " \(name)" } - return " \(name)=\"\(escapeAttribute(attribute.value))\"" + let escaped = Elements.uriAttributes.contains(name.lowercased()) + ? escapeURI(attribute.value) + : escapeAttribute(attribute.value) + return " \(name)=\"\(escaped)\"" + } + + /// Escapes a URI-valued attribute value (XSLT 1.0 16.2, HTML 4.01 B.2.1, + /// the libxml2 HTML output model): each non-ASCII or control character, and + /// every space (leading ones included, where libxml2 skips them), is + /// percent-escaped as the uppercase hex of its UTF-8 bytes, while `"` and + /// `&` keep their entity form. A parse-then-serialize round-trip thus + /// normalizes a space or non-ASCII byte in these attributes to `%HH`; this + /// is idempotent, since a literal `%` is left as is. + private static func escapeURI(_ value: String) -> String { + var result = "" + for scalar in value.unicodeScalars { + if scalar.value > 0x20, scalar.value < 0x7F { + switch scalar { + case "\"": result += """ + case "&": result += "&" + default: result.unicodeScalars.append(scalar) + } + } else { + for byte in String(scalar).utf8 { + result += "%" + hexByte(byte) + } + } + } + return result + } + + /// A byte as two uppercase hex digits, without Foundation formatting. + private static func hexByte(_ byte: UInt8) -> String { + let digits = Array("0123456789ABCDEF") + return String([digits[Int(byte >> 4)], digits[Int(byte & 0x0F)]]) } private static func escapeText(_ value: String) -> String { diff --git a/Sources/HTML/HTMLToken.swift b/Sources/HTML/HTMLToken.swift index 065f425..f8c0cc2 100644 --- a/Sources/HTML/HTMLToken.swift +++ b/Sources/HTML/HTMLToken.swift @@ -43,6 +43,14 @@ extension PureXML.HTML { "defer", "declare", "noresize", "nowrap", "noshade", "compact", ] + /// HTML 4 attributes whose value is a URI (type %URI). The html output + /// method percent-escapes non-ASCII and control characters in their + /// values (XSLT 1.0 16.2, HTML 4.01 appendix B.2.1). + static let uriAttributes: Set = [ + "action", "archive", "background", "cite", "classid", "codebase", + "data", "href", "longdesc", "profile", "src", "usemap", + ] + /// RCDATA elements: like raw-text, but character references in their /// content are decoded (`title`, `textarea`). The remaining raw-text /// elements (`script`, `style`) take their content with no decoding. diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index c6f5139..f69954c 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -27,7 +27,6 @@ namespace/namespace35 numbering/numbering63 output/output22 output/output26 -output/output32 output/output63 output/output70 output/output73 diff --git a/Tests/XSLTHTMLOutputTests.swift b/Tests/XSLTHTMLOutputTests.swift index ab41342..7700164 100644 --- a/Tests/XSLTHTMLOutputTests.swift +++ b/Tests/XSLTHTMLOutputTests.swift @@ -57,6 +57,26 @@ struct XSLTHTMLOutputTests { #expect(try transform(style, "") == "t") } + @Test("the html method percent-escapes non-ASCII and control chars in URI-valued attributes") + func test_uriAttributeEscaping() throws { + // XSLT 1.0 16.2 / HTML 4.01 B.2.1: a URI-valued attribute (cite, href, + // src, ...) percent-escapes non-ASCII (the UTF-8 bytes) and control/space, + // keeping `"` and `&` as entities; a non-URI attribute (title) keeps its + // ordinary html escaping. Apache Xalan output32. + let out = try transform( + htmlStyle(""), "", + ) + #expect(out == "") + } + + @Test("URI-attribute percent-escaping is idempotent") + func test_uriEscapingIdempotent() throws { + // A space becomes %20 once; an already-escaped value is stable because a + // literal `%` is left as is (so a parse-serialize round-trip converges). + #expect(try transform(htmlStyle(""), "") == "") + #expect(try transform(htmlStyle(""), "") == "") + } + @Test("the html method serializes a namespaced element as XML, not HTML") func test_namespacedElementAsXML() throws { // XSLT 1.0 16.2: an element whose expanded-name has a non-null namespace