Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,31 @@ import UIKit
enum BlockType {
case none
case paragraph
case heading
}

struct BlockStyle {
var font: UIFont
var color: UIColor
var headingLevel: Int
}

enum MarkdownAttribute {
static let inlineCode = NSAttributedString.Key("EnrichedMarkdownInlineCode")
}

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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down