diff --git a/Sources/RichText/Models/Configuration.swift b/Sources/RichText/Models/Configuration.swift
index 2df945b..2730d8b 100644
--- a/Sources/RichText/Models/Configuration.swift
+++ b/Sources/RichText/Models/Configuration.swift
@@ -178,17 +178,16 @@ public struct Configuration {
/// - alignment: Text alignment preference
/// - Returns: Generated CSS string
public func css(isLight: Bool, alignment: TextAlignment) -> String {
- let imageCSS = String(format: RichTextConstants.imageCSS, "\(imageRadius)")
- let textCSS = String(
- format: RichTextConstants.textCSS,
- alignment.htmlDescription,
- "\(lineHeight)",
- fontType.name,
- fontColor.value(isLight),
- backgroundColor(isLight)
+ let imageCSS = RichTextConstants.imageCSS(radius: imageRadius)
+ let textCSS = RichTextConstants.textCSS(
+ alignment: alignment.htmlDescription,
+ lineHeight: lineHeight,
+ fontFamily: fontType.name,
+ color: fontColor.value(isLight),
+ backgroundColor: backgroundColor(isLight)
)
- let iframeCSS = String(format: RichTextConstants.iframeCSS, RichTextConstants.iframeHeight)
- let linkCSS = String(format: RichTextConstants.linkCSS, linkColor.value(isLight))
+ let iframeCSS = RichTextConstants.iframeCSS()
+ let linkCSS = RichTextConstants.linkCSS(color: linkColor.value(isLight))
// Add font-specific CSS properties
let fontSpecificCSS = !fontType.additionalCSSProperties.isEmpty ?
diff --git a/Sources/RichText/Models/RichTextConstants.swift b/Sources/RichText/Models/RichTextConstants.swift
index 7ff6a79..e07e4b1 100644
--- a/Sources/RichText/Models/RichTextConstants.swift
+++ b/Sources/RichText/Models/RichTextConstants.swift
@@ -54,12 +54,16 @@ public struct RichTextConstants {
// `loading: lazy` was also dropped: `loading` is an HTML attribute, not a CSS property,
// so no browser ever applied it. Real lazy loading would have to be set on the `
`
// elements themselves.
+ @available(*, deprecated, message: "Use the imageCSS(radius:) builder instead. This is a printf format string, so every literal percent sign has to be written as %%, which is what caused images to lose their max-width.")
public static let imageCSS = "img{height:auto; max-width: 100%%; width:auto;margin-bottom:5px; border-radius: %@px;}"
+ @available(*, deprecated, message: "Use the textCSS(alignment:lineHeight:fontFamily:color:backgroundColor:) builder instead. This is a printf format string, so every literal percent sign has to be written as %%, which is what caused images to lose their max-width.")
public static let textCSS = "h1, h2, h3, h4, h5, h6, p, div, dl, ol, ul, pre, blockquote, figure, figcaption, details, summary, article, section, aside, header, footer, nav, main {text-align:%@; line-height: %@%%; font-family: %@; color: %@; background-color: %@; word-wrap: break-word; }"
// `%ld`, not `%d`: `iframeHeight` is a Swift `Int`, which is 64-bit on every platform this
// package supports, while `%d` reads only 32 bits of it. Matches the `%02lX` spelling
// already used in `Color+Extension`.
+ @available(*, deprecated, message: "Use the iframeCSS(height:) builder instead. This is a printf format string, so every literal percent sign has to be written as %%, which is what caused images to lose their max-width.")
public static let iframeCSS = "iframe{width:100%%; height:%ldpx; border: none;}"
+ @available(*, deprecated, message: "Use the linkCSS(color:) builder instead. This is a printf format string, so every literal percent sign has to be written as %%, which is what caused images to lose their max-width.")
public static let linkCSS = "a:link {color: %@; transition: color 0.2s ease;}"
public static let linkDecorationCSS = "A {text-decoration: none;} A:hover {text-decoration: underline;}"
@available(*, deprecated, message: "Unused. Body margins are emitted directly by cssTemplate and mediaCSSTemplate, and this constant was never applied, so its -webkit-text-size-adjust rule never took effect either.")
@@ -101,6 +105,7 @@ public struct RichTextConstants {
"""
// MARK: - HTML Templates (v3.0.0 - Modern, accessible markup)
+ @available(*, deprecated, message: "Use the htmlDocument(css:body:) builder instead. This is a printf format string, so every literal percent sign has to be written as %%, which is what caused images to lose their max-width.")
public static let htmlTemplate = """
@@ -193,6 +198,7 @@ public struct RichTextConstants {
"""
+ @available(*, deprecated, message: "Use the styleDocument(css:customCSS:) builder instead. This is a printf format string, so every literal percent sign has to be written as %%, which is what caused images to lose their max-width.")
public static let cssTemplate = """
+
+ """
+ }
+
+ /// A `
+
+ """
+ }
+
+ /// The complete HTML document handed to the web view.
+ ///
+ /// The container id and the two script message handler names are constants, so unlike the
+ /// old seven-placeholder format string there is nothing here for a caller to get out of
+ /// order.
+ public static func htmlDocument(css: String, body: String) -> String {
+ """
+
+
+
+
+ \(css)
+ \(body)
+
+
+
+ """
+ }
+}
diff --git a/Sources/RichText/Views/Webview.swift b/Sources/RichText/Views/Webview.swift
index bfb5045..01c680e 100644
--- a/Sources/RichText/Views/Webview.swift
+++ b/Sources/RichText/Views/Webview.swift
@@ -341,16 +341,7 @@ extension WebView {
/// Generates the complete HTML string for the WebView
/// - Returns: Complete HTML document string
func generateHTML() -> String {
- return String(
- format: RichTextConstants.htmlTemplate,
- generateCSS(),
- RichTextConstants.richTextContainerID,
- html,
- RichTextConstants.heightNotificationHandler,
- RichTextConstants.richTextContainerID,
- RichTextConstants.mediaClickHandler,
- RichTextConstants.mediaClickHandler
- )
+ return RichTextConstants.htmlDocument(css: generateCSS(), body: html)
}
/// Generates CSS styles based on color scheme configuration
@@ -358,23 +349,20 @@ extension WebView {
func generateCSS() -> String {
switch conf.colorScheme {
case .light:
- return String(
- format: RichTextConstants.cssTemplate,
- conf.css(isLight: true, alignment: alignment),
- conf.resolvedCustomCSS
+ return RichTextConstants.styleDocument(
+ css: conf.css(isLight: true, alignment: alignment),
+ customCSS: conf.resolvedCustomCSS
)
case .dark:
- return String(
- format: RichTextConstants.cssTemplate,
- conf.css(isLight: false, alignment: alignment),
- conf.resolvedCustomCSS
+ return RichTextConstants.styleDocument(
+ css: conf.css(isLight: false, alignment: alignment),
+ customCSS: conf.resolvedCustomCSS
)
case .auto:
- return String(
- format: RichTextConstants.mediaCSSTemplate,
- conf.css(isLight: true, alignment: alignment),
- conf.css(isLight: false, alignment: alignment),
- conf.resolvedCustomCSS
+ return RichTextConstants.styleDocument(
+ lightCSS: conf.css(isLight: true, alignment: alignment),
+ darkCSS: conf.css(isLight: false, alignment: alignment),
+ customCSS: conf.resolvedCustomCSS
)
}
}
diff --git a/Tests/RichTextTests/RichTextSwiftTestingTests.swift b/Tests/RichTextTests/RichTextSwiftTestingTests.swift
index 64ab07c..9b0700c 100644
--- a/Tests/RichTextTests/RichTextSwiftTestingTests.swift
+++ b/Tests/RichTextTests/RichTextSwiftTestingTests.swift
@@ -87,9 +87,8 @@ struct RichTextAllTests {
#expect(css.contains("max-width: 100%"))
#expect(css.contains("height:auto"))
- // `iframeHeight` is a Swift `Int`, so the format string has to use `%ld`.
- // With `%d` only the low 32 bits are read, which happens to work for small
- // values and silently would not for large ones.
+ // The iframe height is interpolated, so there is no `%d` reading 32 bits of a
+ // 64-bit `Int` any more.
#expect(css.contains("height:\(RichTextConstants.iframeHeight)px"))
}
@@ -120,15 +119,15 @@ struct RichTextAllTests {
#expect(css.contains(expectedAlignment))
}
- @Test("Percent signs survive String(format:) based CSS generation")
- func percentSignsSurviveFormatting() {
+ @Test("Percentage lengths reach the generated CSS intact")
+ func percentageLengthsReachTheCSS() {
let css = Configuration(lineHeight: 150, imageRadius: 5).css(isLight: true, alignment: .leading)
- // The CSS constants that carry substitutions are rendered with `String(format:)`,
- // which consumes a bare `%`. A literal percent sign therefore has to be written as
- // `%%` in those constants. Missing one is silent: `max-width: 100%` becomes
- // `max-width: 100`, an invalid length that WebKit drops, and images then overflow
- // the web view instead of being constrained to its width.
+ // These used to go through `String(format:)`, which consumes a bare `%`, so every
+ // literal percent sign had to be written `%%`. Missing one was silent:
+ // `max-width: 100%` became `max-width: 100`, an invalid length that WebKit drops,
+ // and images overflowed the web view. The builders interpolate instead, so the
+ // escaping cannot be forgotten - this just pins the output down.
#expect(css.contains("max-width: 100%"))
#expect(css.contains("width:100%;"))
#expect(css.contains("line-height: 150.0%"))
@@ -136,51 +135,6 @@ struct RichTextAllTests {
#expect(!css.contains("max-width: 100;"))
#expect(!css.contains("width:100;"))
}
-
- @Test("Percent based CSS constants are escaped for String(format:)", arguments: [
- ("imageCSS", RichTextConstants.imageCSS),
- ("textCSS", RichTextConstants.textCSS),
- ("iframeCSS", RichTextConstants.iframeCSS),
- ("linkCSS", RichTextConstants.linkCSS),
- ("cssTemplate", RichTextConstants.cssTemplate),
- ("mediaCSSTemplate", RichTextConstants.mediaCSSTemplate),
- ("htmlTemplate", RichTextConstants.htmlTemplate)
- ])
- func percentBasedConstantsAreEscaped(name: String, constant: String) {
- // Covers every constant that `Configuration.css(isLight:alignment:)`,
- // `WebView.generateCSS()` and `WebView.generateHTML()` pass to `String(format:)`.
- //
- // Look for the shape the bug actually takes rather than whitelisting conversion
- // characters. A literal percent sign in CSS is always followed by a delimiter -
- // `100%;`, `100% }`, `100%,` - while a conversion is followed by a specifier or a
- // length modifier. Checking the delimiters catches a missing `%%` without pinning
- // down which conversions the constants are allowed to use, so `%d` can still be
- // widened to `%ld` later without this test standing in the way.
- let cssDelimiters: Set = [";", "}", ",", ")", " ", "\n", "\t"]
- let characters = Array(constant)
- var index = 0
-
- while index < characters.count {
- guard characters[index] == "%" else {
- index += 1
- continue
- }
-
- let next = index + 1 < characters.count ? characters[index + 1] : nil
-
- // `%%` is an escaped literal percent, which is exactly what we want to see.
- if next == "%" {
- index += 2
- continue
- }
-
- #expect(
- next != nil && !cssDelimiters.contains(next!),
- "Unescaped percent sign in \(name): a literal % must be written as %%"
- )
- index += 1
- }
- }
}
@Suite("ColorSet Tests")
@@ -385,44 +339,31 @@ struct RichTextAllTests {
#expect(!RichTextConstants.systemFontName.isEmpty)
}
- @Test("HTML template keeps measuring the content after the first layout pass")
- func htmlTemplateObservesHeightChanges() {
- let template = RichTextConstants.htmlTemplate
+ @Test("Generated document keeps measuring the content after the first layout pass")
+ func generatedDocumentObservesHeightChanges() {
+ let document = RichTextConstants.htmlDocument(css: "", body: "Hello
")
// A one-shot `window.onload` measurement misses late images, web fonts and
// `` toggles, which is what caused the content to be clipped.
- #expect(template.contains("ResizeObserver"))
- #expect(template.contains("MutationObserver"))
- #expect(template.contains("document.addEventListener('toggle', syncHeight, true)"))
- #expect(template.contains("document.fonts.ready"))
- #expect(!template.contains("window.onload = function"))
- }
-
- @Test("HTML template placeholders stay in the order WebView.generateHTML supplies them")
- func htmlTemplatePlaceholderOrder() {
- let placeholderCount = RichTextConstants.htmlTemplate.components(separatedBy: "%@").count - 1
-
- // css, container id, html body, height handler, container id, media handler, media handler
- #expect(placeholderCount == 7)
-
- // Mirrors the argument list in `WebView.generateHTML()`. `String(format:)` is
- // positional, so reordering the placeholders inside the template silently
- // produces a broken document; this pins the mapping down.
- let document = String(
- format: RichTextConstants.htmlTemplate,
- "",
- RichTextConstants.richTextContainerID,
- "Hello
",
- RichTextConstants.heightNotificationHandler,
- RichTextConstants.richTextContainerID,
- RichTextConstants.mediaClickHandler,
- RichTextConstants.mediaClickHandler
- )
+ #expect(document.contains("ResizeObserver"))
+ #expect(document.contains("MutationObserver"))
+ #expect(document.contains("document.addEventListener('toggle', syncHeight, true)"))
+ #expect(document.contains("document.fonts.ready"))
+ #expect(!document.contains("window.onload = function"))
+ }
+
+ @Test("Generated document wires the script bridge to the rendered container")
+ func generatedDocumentWiresTheBridge() {
+ let document = RichTextConstants.htmlDocument(css: "", body: "Hello
")
#expect(document.contains(""))
#expect(document.contains("var richTextHeightHandler = '\(RichTextConstants.heightNotificationHandler)'"))
#expect(document.contains("document.getElementById('\(RichTextConstants.richTextContainerID)')"))
#expect(document.contains("window.webkit.messageHandlers.\(RichTextConstants.mediaClickHandler).postMessage({"))
+
+ // The container id and the handler names are constants inside the builder, so
+ // there are no positional arguments left for a caller to get out of order.
+ #expect(!document.contains("%@"))
}
}