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:
parent
f1bc46e99b
commit
74bf622275
@ -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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -27,7 +27,6 @@ namespace/namespace35
|
||||
numbering/numbering63
|
||||
output/output22
|
||||
output/output26
|
||||
output/output32
|
||||
output/output63
|
||||
output/output70
|
||||
output/output73
|
||||
|
||||
@ -57,6 +57,26 @@ struct XSLTHTMLOutputTests {
|
||||
#expect(try transform(style, "<x/>") == "<a title=\"<x>\">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ë.xml\" title=\"bë\" href=\"a b&c\"/>"), "<x/>",
|
||||
)
|
||||
#expect(out == "<q cite=\"b%C3%AB.xml\" title=\"bë\" href=\"a%20b&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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user