diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift index e3993d505..2b946913e 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift @@ -3,11 +3,13 @@ import UIKit enum BlockType { case none case paragraph + case heading } struct BlockStyle { var font: UIFont var color: UIColor + var headingLevel: Int } enum MarkdownAttribute { @@ -15,17 +17,17 @@ enum MarkdownAttribute { } final class RenderContext { - private(set) var currentBlockType: BlockType = .none - private(set) var currentBlockStyle: BlockStyle? + private(set) var currentBlockType: BlockType = .none + private(set) var currentBlockStyle: BlockStyle? func reset() { currentBlockType = .none currentBlockStyle = nil } - func setBlockStyle(font: UIFont, color: UIColor) { - currentBlockType = .paragraph - currentBlockStyle = BlockStyle(font: font, color: color) + func setBlockStyle(font: UIFont, color: UIColor, blockType: BlockType = .paragraph, headingLevel: Int = 0) { + currentBlockType = blockType + currentBlockStyle = BlockStyle(font: font, color: color, headingLevel: headingLevel) } func clearBlockStyle() { diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift index bf8265125..6f0ba39e9 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift @@ -39,6 +39,8 @@ final class RendererFactory { return StrongRenderer(factory: self, config: config) case .emphasis: return EmphasisRenderer(factory: self, config: config) + case .heading: + return HeadingRenderer(factory: self, config: config) default: return childrenOnlyRenderer } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/HeadingRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/HeadingRenderer.swift new file mode 100644 index 000000000..1aecc2f7c --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/HeadingRenderer.swift @@ -0,0 +1,88 @@ +import UIKit + +final class HeadingRenderer: NodeRenderer { + private let factory: RendererFactory + private let config: MarkdownStyleConfig + + init(factory: RendererFactory, config: MarkdownStyleConfig) { + self.factory = factory + self.config = config + } + + func render(node: MarkdownASTNode, into output: NSMutableAttributedString, context: RenderContext) { + let level = headingLevel(from: node) + let headingStyle = config.headingStyle(for: level) + + let font = headingStyle.font ?? defaultFont(for: level) + let color = headingStyle.foregroundColor ?? UIColor.label + context.setBlockStyle(font: font, color: color, blockType: .heading, headingLevel: level) + + let start = output.length + let marginTop = headingStyle.marginTop ?? 0 + var contentStart = start + + if start == 0, marginTop > 0 { + let offset = ParagraphStyleHelpers.applyBlockSpacingBefore( + to: output, + at: 0, + marginTop: marginTop + ) + contentStart += offset + } + + factory.renderChildren(of: node, into: output, context: context) + context.clearBlockStyle() + + guard output.length > start else { return } + + let range = NSRange(location: start, length: output.length - start) + + if let lineHeight = headingStyle.lineHeight { + ParagraphStyleHelpers.applyBlockLineHeight(to: output, range: range, lineHeight: lineHeight) + } + + if let alignment = headingStyle.textAlignment { + ParagraphStyleHelpers.applyTextAlignment(to: output, range: range, alignment: alignment) + } + + var spacingStart = start + if contentStart != 1 { + spacingStart += ParagraphStyleHelpers.applyParagraphSpacingBefore( + to: output, + range: range, + marginTop: marginTop + ) + } + + if let marginBottom = headingStyle.marginBottom { + ParagraphStyleHelpers.applyParagraphSpacingAfter( + to: output, + at: spacingStart, + marginBottom: marginBottom + ) + } + } + + private func headingLevel(from node: MarkdownASTNode) -> Int { + guard let levelString = node.attribute("level"), let level = Int(levelString) else { + return 1 + } + return max(1, min(level, 6)) + } + + private func defaultFont(for level: Int) -> UIFont { + UIFont.systemFont(ofSize: Self.defaultPointSize(for: level), weight: .regular) + } + + /// Matches Android `DefaultStyles` heading sizes and regular (400) weight. + private static func defaultPointSize(for level: Int) -> CGFloat { + switch level { + case 1: return 30 + case 2: return 24 + case 3: return 20 + case 4: return 18 + case 5: return 16 + default: return 14 + } + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift index e79d1ae53..b5ad9e380 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift @@ -11,6 +11,40 @@ enum DefaultMarkdownTheme { .lineHeight(26) .marginBottom(16) + Heading(1) + .font(.largeTitle) + .bold() + .foregroundStyle(Semantic.primary) + .marginBottom(8) + + Heading(2) + .font(.title) + .bold() + .foregroundStyle(Semantic.primary) + .marginBottom(8) + + Heading(3) + .font(.title2) + .bold() + .foregroundStyle(Semantic.primary) + .marginBottom(8) + + Heading(4) + .font(.title3) + .bold() + .foregroundStyle(Semantic.primary) + .marginBottom(8) + + Heading(5) + .font(.headline) + .foregroundStyle(Semantic.primary) + .marginBottom(8) + + Heading(6) + .font(.subheadline) + .foregroundStyle(Semantic.secondary) + .marginBottom(8) + Strong() Emphasis() } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/Heading.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/Heading.swift new file mode 100644 index 000000000..4b3750f6b --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/Heading.swift @@ -0,0 +1,23 @@ +import SwiftUI + +public struct Heading: MarkdownThemeElement { + public let level: Int + public var fontSpec: ThemeFontSpec? + public var fontWeight: Font.Weight? + public var fontDesign: Font.Design? + public var foregroundColorSpec: ThemeColorSpec? + public var marginTop: CGFloat? + public var marginBottom: CGFloat? + public var lineHeight: CGFloat? + public var textAlignment: TextAlignment? + + public init(_ level: Int) { + self.level = max(1, min(level, 6)) + } + + public func apply(to config: inout MarkdownStyleConfig, traitCollection: UITraitCollection) { + var style = config.headingStyle(for: level) + applyElementStyle(to: &style, traitCollection: traitCollection) + config.setHeadingStyle(style, for: level) + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift index a4ee160e3..934b01b76 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift @@ -36,23 +36,70 @@ public struct ElementStyle: Equatable, Sendable { public struct MarkdownStyleConfig: Equatable, Sendable { public var paragraph: ElementStyle + public var heading1: ElementStyle + public var heading2: ElementStyle + public var heading3: ElementStyle + public var heading4: ElementStyle + public var heading5: ElementStyle + public var heading6: ElementStyle public var strong: ElementStyle public var emphasis: ElementStyle public init( paragraph: ElementStyle = ElementStyle(), + heading1: ElementStyle = ElementStyle(), + heading2: ElementStyle = ElementStyle(), + heading3: ElementStyle = ElementStyle(), + heading4: ElementStyle = ElementStyle(), + heading5: ElementStyle = ElementStyle(), + heading6: ElementStyle = ElementStyle(), strong: ElementStyle = ElementStyle(), emphasis: ElementStyle = ElementStyle() ) { self.paragraph = paragraph + self.heading1 = heading1 + self.heading2 = heading2 + self.heading3 = heading3 + self.heading4 = heading4 + self.heading5 = heading5 + self.heading6 = heading6 self.strong = strong self.emphasis = emphasis } public mutating func merge(_ other: MarkdownStyleConfig) { paragraph.merge(other.paragraph) + heading1.merge(other.heading1) + heading2.merge(other.heading2) + heading3.merge(other.heading3) + heading4.merge(other.heading4) + heading5.merge(other.heading5) + heading6.merge(other.heading6) strong.merge(other.strong) emphasis.merge(other.emphasis) } + public func headingStyle(for level: Int) -> ElementStyle { + switch level { + case 1: return heading1 + case 2: return heading2 + case 3: return heading3 + case 4: return heading4 + case 5: return heading5 + case 6: return heading6 + default: return heading1 + } + } + + public mutating func setHeadingStyle(_ style: ElementStyle, for level: Int) { + switch level { + case 1: heading1 = style + case 2: heading2 = style + case 3: heading3 = style + case 4: heading4 = style + case 5: heading5 = style + case 6: heading6 = style + default: heading1 = style + } + } } diff --git a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift index 3a695aa60..ec01242ad 100644 --- a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift +++ b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift @@ -155,6 +155,60 @@ final class RendererTests: XCTestCase { XCTAssertNotNil(baselineOffset) XCTAssertGreaterThan(baselineOffset ?? 0, 0) } + + + func testHeadingUsesLargerFontThanBody() { + let result = MarkdownRenderer.render("# Heading", config: config) + XCTAssertTrue(result.string.contains("Heading")) + + let bodyFont = config.paragraph.font ?? UIFont.preferredFont(forTextStyle: .body) + var effectiveRange = NSRange() + let attributes = result.attributes(at: 0, effectiveRange: &effectiveRange) + let headingFont = attributes[.font] as? UIFont + XCTAssertNotNil(headingFont) + XCTAssertGreaterThan(headingFont!.pointSize, bodyFont.pointSize) + } + + + func testHeadingLevel2UsesConfiguredFont() { + let result = MarkdownRenderer.render("## Sub", config: config) + XCTAssertTrue(result.string.contains("Sub")) + + let expectedFont = config.heading2.font ?? UIFont.systemFont(ofSize: 24, weight: .regular) + var effectiveRange = NSRange() + let attributes = result.attributes(at: 0, effectiveRange: &effectiveRange) + let font = attributes[.font] as? UIFont + XCTAssertEqual(font?.pointSize, expectedFont.pointSize) + } + + + func testHeadingUsesBoldWeightInDefaultTheme() { + let result = MarkdownRenderer.render("# Heading", config: config) + + var effectiveRange = NSRange() + let attributes = result.attributes(at: 0, effectiveRange: &effectiveRange) + let font = attributes[.font] as? UIFont + XCTAssertNotNil(font) + XCTAssertTrue(font!.fontDescriptor.symbolicTraits.contains(.traitBold)) + } + + + func testBoldInsideHeading() { + let result = MarkdownRenderer.render("# **bold** heading", config: config) + + var boldFound = false + result.enumerateAttribute(.font, in: NSRange(location: 0, length: result.length)) { value, range, _ in + guard let font = value as? UIFont else { return } + let substring = (result.string as NSString).substring(with: range) + if substring.contains("bold") { + XCTAssertTrue(font.fontDescriptor.symbolicTraits.contains(.traitBold)) + let headingFont = config.heading1.font ?? UIFont.systemFont(ofSize: 30, weight: .regular) + XCTAssertGreaterThanOrEqual(font.pointSize, headingFont.pointSize) + boldFound = true + } + } + XCTAssertTrue(boldFound) + } } private extension String {