Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,13 @@ RichText(html: String, configuration: Configuration, placeholder: AnyView?)
.onError { error in
switch error {
case .htmlLoadingFailed(let html):
// The web view failed to load the generated document
print("Failed to load HTML: \(html)")
case .webViewConfigurationFailed:
// The web view could not be configured as requested (macOS transparency)
print("WebView configuration failed")
case .cssGenerationFailed:
// A colour was given as an invalid hex value, so the browser drops the declaration
print("CSS generation failed")
case .mediaHandlingFailed(let media):
print("Media handling failed: \(media)")
Expand Down Expand Up @@ -361,7 +364,6 @@ let config = Configuration(
baseURL: Bundle.main.bundleURL,
mediaClickHandler: { media in /* handle clicks */ },
errorHandler: { error in /* handle errors */ },
isColorsImportant: .onlyLinks,
transition: .easeInOut(duration: 0.3)
)

Expand Down Expand Up @@ -556,7 +558,6 @@ RichText(html: largeHtmlContent)
img {
max-width: 100%;
height: auto;
loading: lazy; /* Native lazy loading */
}
""")
.loadingTransition(.none) // Disable transitions for faster rendering
Expand Down
21 changes: 18 additions & 3 deletions Sources/RichText/Models/ColorSet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,29 @@ public struct ColorSet: Equatable {
}

/// Validates if the hex color strings are valid
///
/// An invalid value is not rejected anywhere - it is interpolated into the stylesheet as
/// `color: #whatever`, which the browser silently drops along with the rest of the
/// declaration. ``RichText`` reports this through ``RichTextError/cssGenerationFailed``.
///
/// - Returns: true if both light and dark hex values are valid
public var isValid: Bool {
return isValidHexColor(light) && isValidHexColor(dark)
}

private func isValidHexColor(_ hex: String) -> Bool {
let hexRegex = "^[0-9A-Fa-f]{6}$|^[0-9A-Fa-f]{8}$" // 6 or 8 characters
let predicate = NSPredicate(format: "SELF MATCHES %@", hexRegex)
return predicate.evaluate(with: hex)
// `#RGB`, `#RGBA`, `#RRGGBB` and `#RRGGBBAA` are all valid CSS colours. The previous
// 6-or-8 rule rejected the two shorthand forms as invalid even though they render
// fine, and disagreed with `BackgroundColor.isHexColorLiteral`, which already
// accepted all four lengths.
let validLengths = [3, 4, 6, 8]

guard validLengths.contains(hex.count) else {
return false
}

return hex.allSatisfy { character in
character.isHexDigit
}
}
}
6 changes: 6 additions & 0 deletions Sources/RichText/Models/RichTextEnums.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,15 @@ public typealias MediaClickHandler = (MediaClickType) -> Void

/// RichText error types
public enum RichTextError: LocalizedError {
/// The web view failed to load the generated document.
case htmlLoadingFailed(String)
/// The web view could not be configured as requested, so it will not look or behave as
/// the configuration asked for.
case webViewConfigurationFailed
/// The generated CSS will not be applied as intended, for example because a colour was
/// given as an invalid hex value and the browser will drop the declaration.
case cssGenerationFailed
/// A media click message from the page could not be handled.
case mediaHandlingFailed(String)

public var errorDescription: String? {
Expand Down
23 changes: 21 additions & 2 deletions Sources/RichText/Views/Webview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,18 @@ extension WebView: NSViewRepresentable {
// Set delegate
webview.navigationDelegate = context.coordinator

// Configure appearance
webview.setValue(false, forKey: "drawsBackground")
// Configure appearance.
//
// `drawsBackground` is not part of WKWebView's public API, it is reached through KVC.
// `setValue(_:forKey:)` raises NSUnknownKeyException if the key ever goes away, and
// that is an Objective-C exception Swift cannot catch, so it would take the host app
// down. Probe for the setter and report a configuration failure instead.
if webview.responds(to: NSSelectorFromString("setDrawsBackground:")) {
webview.setValue(false, forKey: "drawsBackground")
} else {
webViewLogger.error("WKWebView no longer exposes drawsBackground; background will not be transparent")
conf.errorHandler?(.webViewConfigurationFailed)
}

// Load HTML content
loadHTMLIfNeeded(in: webview, coordinator: context.coordinator)
Expand Down Expand Up @@ -311,6 +321,15 @@ extension WebView {
return
}

// A malformed hex value is not rejected anywhere: it reaches the stylesheet as
// `color: #whatever`, and the browser drops that declaration along with the rest of
// the rule. The text or link colour then silently falls back to the default, which is
// very hard to trace from the outside, so surface it.
if !conf.fontColor.isValid || !conf.linkColor.isValid {
webViewLogger.error("Invalid hex colour in fontColor or linkColor; the declaration will be dropped by the browser")
conf.errorHandler?(.cssGenerationFailed)
}

coordinator.loadedHTML = htmlString
coordinator.loadedBaseURL = baseURL

Expand Down
23 changes: 23 additions & 0 deletions Tests/RichTextTests/RichTextSwiftTestingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,29 @@ struct RichTextAllTests {

let invalidColorSet = ColorSet(light: "INVALID", dark: "00FF00")
#expect(!invalidColorSet.isValid)

// `#RGB` and `#RGBA` are valid CSS and were previously reported as invalid.
#expect(ColorSet(light: "F00", dark: "0F0").isValid)
#expect(ColorSet(light: "F00A", dark: "0F0A").isValid)

// Wrong length, right length but not hex, and empty.
#expect(!ColorSet(light: "FF000", dark: "00FF00").isValid)
#expect(!ColorSet(light: "GGGGGG", dark: "00FF00").isValid)
#expect(!ColorSet(light: "", dark: "00FF00").isValid)
}

@Test("Every RichTextError case describes itself")
func errorCasesAreDescribed() {
let errors: [RichTextError] = [
.htmlLoadingFailed("<p>x</p>"),
.webViewConfigurationFailed,
.cssGenerationFailed,
.mediaHandlingFailed("image")
]

for error in errors {
#expect(error.errorDescription?.isEmpty == false)
}
}

@Test("ColorSet raw values work correctly")
Expand Down