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("

Hello

")) #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("%@")) } }