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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-24 03:27:49 +02:00 committed by GitHub
parent f1bc46e99b
commit 74bf622275
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 65 additions and 2 deletions

View File

@ -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.

View File

@ -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 += "&quot;"
case "&": result += "&amp;"
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 {

View File

@ -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<String> = [
"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.

View File

@ -27,7 +27,6 @@ namespace/namespace35
numbering/numbering63
output/output22
output/output26
output/output32
output/output63
output/output70
output/output73

View File

@ -57,6 +57,26 @@ struct XSLTHTMLOutputTests {
#expect(try transform(style, "<x/>") == "<a title=\"&lt;x&gt;\">t</a>")
}
@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("<q cite=\"b&#235;.xml\" title=\"b&#235;\" href=\"a b&amp;c\"/>"), "<x/>",
)
#expect(out == "<q cite=\"b%C3%AB.xml\" title=\"b&euml;\" href=\"a%20b&amp;c\"></q>")
}
@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("<a href=\"x y\"/>"), "<x/>") == "<a href=\"x%20y\"></a>")
#expect(try transform(htmlStyle("<a href=\"x%20y\"/>"), "<x/>") == "<a href=\"x%20y\"></a>")
}
@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