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
41 changes: 38 additions & 3 deletions Sources/RichText/Models/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,41 @@ public struct Configuration {
}


/// `customCSS` plus the framework-managed overrides that have to win the cascade.
///
/// Dynamic Type is expressed with the `font` shorthand (`font: -apple-system-body`), and a
/// shorthand resets every longhand it covers, which includes `font-family` and `font-style`.
/// Since `customCSS` is emitted after the generated rules, turning on `supportsDynamicType`
/// silently discarded `fontType`: `.monospaced`, `.italic` and `.customName` all fell back to
/// the default family. Re-assert the configured font after the Dynamic Type rules so the two
/// options can be used together.
public var resolvedCustomCSS: String {
guard supportsDynamicType, requiresFontOverride else {
return customCSS
}

// `p.subheadline` and friends have to be named explicitly. A bare `p` selector has
// specificity (0,0,1) and would lose to the Dynamic Type `p.subheadline { font: ... }`
// rule at (0,1,1) no matter how late it appears, so those paragraphs would keep
// resetting `font-family`. Matching the specificity lets source order decide, and the
// override is emitted last.
let fontOverrideCSS = """
html, body, h1, h2, h3, h4, h5, h6, p, p.subheadline, p.footnote, p.caption1, p.caption2 { font-family: \(fontType.name); \(fontType.additionalCSSProperties) }
"""

return customCSS + "\n" + fontOverrideCSS
}

/// Whether the configured font actually differs from what the Dynamic Type shorthands
/// already produce.
///
/// For the default system font the shorthand result and the configured font are the same,
/// so emitting an override would buy nothing and would clobber a caller's own
/// `font-family` rule in `customCSS`.
private var requiresFontOverride: Bool {
fontType.name != RichTextConstants.systemFontName || !fontType.additionalCSSProperties.isEmpty
}

private func backgroundColor(_ isLight: Bool) -> String {
let baseColor: String

Expand Down Expand Up @@ -162,9 +197,9 @@ public struct Configuration {

switch scheme {
case .light:
return css(isLight: true, alignment: alignment) + "\n" + customCSS
return css(isLight: true, alignment: alignment) + "\n" + resolvedCustomCSS
case .dark:
return css(isLight: false, alignment: alignment) + "\n" + customCSS
return css(isLight: false, alignment: alignment) + "\n" + resolvedCustomCSS
case .auto:
return """
@media (prefers-color-scheme: light) {
Expand All @@ -173,7 +208,7 @@ public struct Configuration {
@media (prefers-color-scheme: dark) {
\(css(isLight: false, alignment: alignment))
}
\(customCSS)
\(resolvedCustomCSS)
"""
}
}
Expand Down
6 changes: 3 additions & 3 deletions Sources/RichText/Views/Webview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,20 +303,20 @@ extension WebView {
return String(
format: RichTextConstants.cssTemplate,
conf.css(isLight: true, alignment: alignment),
conf.customCSS
conf.resolvedCustomCSS
)
case .dark:
return String(
format: RichTextConstants.cssTemplate,
conf.css(isLight: false, alignment: alignment),
conf.customCSS
conf.resolvedCustomCSS
)
case .auto:
return String(
format: RichTextConstants.mediaCSSTemplate,
conf.css(isLight: true, alignment: alignment),
conf.css(isLight: false, alignment: alignment),
conf.customCSS
conf.resolvedCustomCSS
)
}
}
Expand Down
75 changes: 75 additions & 0 deletions Tests/RichTextTests/RichTextSwiftTestingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,81 @@ struct RichTextAllTests {
#expect(FontType.monospaced.name != RichTextConstants.systemFontName)
#expect(FontType.italic.name == RichTextConstants.systemFontName)
}

@Test("Font names are valid CSS identifiers or quoted strings")
func fontNamesAreValidCSS() {
// A raw PostScript name such as `.AppleSystemUIFontMonospaced-Regular` is not a
// valid unquoted CSS family, so WebKit dropped the whole declaration and fell back
// to the default serif face.
#expect(!FontType.monospaced.name.hasPrefix("."))
#expect(!FontType.customName("Custom Font").name.hasPrefix("."))
#expect(FontType.customName("Custom Font").name == "'Custom Font'")
#expect(FontType.monospaced.name.contains("monospace"))
}

@Test("Dynamic Type does not discard the configured font type", arguments: [
FontType.monospaced,
FontType.italic,
FontType.customName("Custom Font")
])
func dynamicTypeKeepsFontType(fontType: FontType) {
let config = Configuration(supportsDynamicType: true, fontType: fontType)
let css = config.generateCompleteCSS(colorScheme: .light, alignment: .leading)

// Compare against the *last* Dynamic Type shorthand, not the first: every one of
// them resets `font-family`, so the override only works if it follows all of them.
let lastShorthandRange = css.range(of: "font: -apple-system-", options: .backwards)
let familyRange = css.range(of: "font-family: \(fontType.name)", options: .backwards)

#expect(lastShorthandRange != nil)
#expect(familyRange != nil)

if let lastShorthandRange, let familyRange {
#expect(familyRange.lowerBound > lastShorthandRange.lowerBound)
}
}

@Test("Font override matches the specificity of the Dynamic Type paragraph classes")
func dynamicTypeOverrideCoversParagraphClasses() {
let config = Configuration(supportsDynamicType: true, fontType: .monospaced)
let css = config.generateCompleteCSS(colorScheme: .light, alignment: .leading)

let overrideLine = css
.split(separator: "\n")
.last { $0.contains("font-family: \(FontType.monospaced.name)") }

#expect(overrideLine != nil)

// A bare `p` selector is specificity (0,0,1) and loses to the Dynamic Type
// `p.subheadline { font: ... }` rule at (0,1,1) regardless of source order, so
// those classes have to be named explicitly in the override.
if let overrideLine {
for selector in ["p.subheadline", "p.footnote", "p.caption1", "p.caption2"] {
#expect(overrideLine.contains(selector), "override must also match \(selector)")
}
}
}

@Test("No font override is emitted for the default system font")
func noFontOverrideForSystemFont() {
// `.system` is exactly what the Dynamic Type shorthands already produce, so an
// override would buy nothing and would clobber the caller's own font-family rule.
let config = Configuration(
customCSS: "body { font-family: Georgia; }",
supportsDynamicType: true
)

#expect(config.fontType.name == RichTextConstants.systemFontName)
#expect(config.resolvedCustomCSS == config.customCSS)
}

@Test("Custom CSS is untouched when Dynamic Type is off")
func resolvedCustomCSSWithoutDynamicType() {
let config = Configuration(customCSS: "p { color: red; }", fontType: .monospaced)

#expect(config.resolvedCustomCSS == "p { color: red; }")
#expect(config.resolvedCustomCSS == config.customCSS)
}
}

@Suite("RichText Initialization Tests")
Expand Down
Loading