From 6b0c8a47e63dfe0df9abf27e4d148e2473fafe1c Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Thu, 2 Jul 2026 19:15:06 +0200 Subject: [PATCH] feat(ios): add list rendering and theme Co-authored-by: Cursor --- .../Rendering/RendererFactory.swift | 6 + .../Renderers/ListItemRenderer.swift | 119 +++++++++++ .../Rendering/Renderers/ListRenderer.swift | 56 ++++++ .../Theme/DefaultMarkdownTheme.swift | 9 + .../Theme/Elements/List.swift | 100 ++++++++++ .../Theme/MarkdownStyleConfig.swift | 60 +++++- .../Views/Layout/BlockDecorationConfig.swift | 25 +++ .../Views/Layout/ListMarkerDrawer.swift | 187 ++++++++++++++++++ .../Layout/MarkdownViewportDecorator.swift | 41 +++- .../Views/Layout/TextLayoutHelpers.swift | 10 + .../EnrichedMarkdownTests/RendererTests.swift | 89 +++++++++ 11 files changed, 691 insertions(+), 11 deletions(-) create mode 100644 packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/ListItemRenderer.swift create mode 100644 packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/ListRenderer.swift create mode 100644 packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/List.swift create mode 100644 packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/ListMarkerDrawer.swift diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift index c1f46d1e8..4f8ca53c0 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift @@ -72,6 +72,12 @@ final class RendererFactory { return CodeBlockRenderer(factory: self, config: config) case .blockquote: return BlockquoteRenderer(factory: self, config: config) + case .unorderedList: + return ListRenderer(factory: self, config: config, isOrdered: false) + case .orderedList: + return ListRenderer(factory: self, config: config, isOrdered: true) + case .listItem: + return ListItemRenderer(factory: self, config: config) default: return nil } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/ListItemRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/ListItemRenderer.swift new file mode 100644 index 000000000..dc1924a0d --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/ListItemRenderer.swift @@ -0,0 +1,119 @@ +import UIKit + +final class ListItemRenderer: 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) { + context.listItemNumber += 1 + let currentPosition = context.listItemNumber + let currentDepth = context.listDepth + let nestingLevel = currentDepth - 1 + + let startLocation = output.length + factory.renderChildren(of: node, into: output, context: context) + ParagraphStyleHelpers.ensureTrailingNewline(in: output) + + let itemRange = NSRange(location: startLocation, length: output.length - startLocation) + guard itemRange.length > 0 else { return } + + let baseMarkerWidth = effectiveMarkerWidth(for: context.listType) + let gapWidth = max(config.list.gapWidth ?? 12, 4) + let marginLeft = config.list.marginLeft ?? 24 + let totalIndent = baseMarkerWidth + gapWidth + (CGFloat(nestingLevel) * marginLeft) + let lineHeight = config.list.lineHeight ?? 0 + + let metadata: [NSAttributedString.Key: Any] = [ + MarkdownAttribute.listDepth: nestingLevel, + MarkdownAttribute.listType: context.listType.rawValue, + MarkdownAttribute.listItemNumber: currentPosition + ] + + applyListItemStyling( + to: output, + itemRange: itemRange, + nestingLevel: nestingLevel, + metadata: metadata, + totalIndent: totalIndent, + lineHeight: lineHeight + ) + } + + private func effectiveMarkerWidth(for listType: ListType) -> CGFloat { + let minWidth = config.list.markerMinWidth ?? 0 + switch listType { + case .ordered: + return max(minWidth, 20) + case .unordered: + return max(minWidth, config.list.bulletSize ?? 6) + } + } + + private func applyListItemStyling( + to output: NSMutableAttributedString, + itemRange: NSRange, + nestingLevel: Int, + metadata: [NSAttributedString.Key: Any], + totalIndent: CGFloat, + lineHeight: CGFloat + ) { + let string = output.string as NSString + var location = itemRange.location + let end = NSMaxRange(itemRange) + + while location < end { + let paragraphRange = string.paragraphRange(for: NSRange(location: location, length: 0)) + let applyRange = NSIntersectionRange(paragraphRange, itemRange) + guard applyRange.length > 0 else { break } + + if shouldSkipListStyling(in: output, range: applyRange, nestingLevel: nestingLevel) { + location = NSMaxRange(applyRange) + continue + } + + let style = NSMutableParagraphStyle() + style.firstLineHeadIndent = totalIndent + style.headIndent = totalIndent + + if lineHeight > 0 { + style.minimumLineHeight = lineHeight + style.maximumLineHeight = lineHeight + } + + var attributes = metadata + attributes[.paragraphStyle] = style + output.addAttributes(attributes, range: applyRange) + + if lineHeight > 0 { + ParagraphStyleHelpers.applyBaselineOffset(to: output, range: applyRange) + } + + location = NSMaxRange(applyRange) + } + } + + private func shouldSkipListStyling( + in output: NSMutableAttributedString, + range: NSRange, + nestingLevel: Int + ) -> Bool { + if let depth = MarkdownAttributeValue.intValue( + from: output.attribute(MarkdownAttribute.listDepth, at: range.location, effectiveRange: nil) + ), depth > nestingLevel { + return true + } + + if MarkdownAttributeValue.boolValue( + from: output.attribute(MarkdownAttribute.codeBlock, at: range.location, effectiveRange: nil) + ) { + return true + } + + return false + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/ListRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/ListRenderer.swift new file mode 100644 index 000000000..15b25573d --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/ListRenderer.swift @@ -0,0 +1,56 @@ +import UIKit + +final class ListRenderer: NodeRenderer { + private let factory: RendererFactory + private let config: MarkdownStyleConfig + private let isOrdered: Bool + + init(factory: RendererFactory, config: MarkdownStyleConfig, isOrdered: Bool) { + self.factory = factory + self.config = config + self.isOrdered = isOrdered + } + + func render(node: MarkdownASTNode, into output: NSMutableAttributedString, context: RenderContext) { + let prevDepth = context.listDepth + let prevType = context.listType + let prevNumber = context.listItemNumber + let startLocation = output.length + + if prevDepth == 0 { + ParagraphStyleHelpers.ensureStartingOnNewLine(in: output) + _ = ParagraphStyleHelpers.applyBlockSpacingBefore( + to: output, + at: startLocation, + marginTop: config.list.marginTop ?? 0 + ) + } else if output.length > 0, !output.string.hasSuffix("\n") { + output.append(ParagraphStyleHelpers.newline) + } + + context.listDepth = prevDepth + 1 + context.listType = isOrdered ? .ordered : .unordered + context.listItemNumber = 0 + + let font = config.list.font ?? UIFont.preferredFont(forTextStyle: .body) + let color = config.list.foregroundColor ?? UIColor.label + context.setBlockStyle( + font: font, + color: color, + blockType: isOrdered ? .orderedList : .unorderedList + ) + + factory.renderChildren(of: node, into: output, context: context) + + context.listDepth = prevDepth + context.listType = prevType + context.listItemNumber = prevNumber + + if prevDepth == 0 { + context.clearBlockStyle() + if let marginBottom = config.list.marginBottom, marginBottom > 0 { + ParagraphStyleHelpers.applyBlockSpacingAfter(to: output, marginBottom: marginBottom) + } + } + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift index ac014a047..e1de7f827 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift @@ -86,6 +86,15 @@ enum DefaultMarkdownTheme { .borderWidth(3) .gapWidth(16) .marginBottom(16) + + List() + .font(.body) + .foregroundStyle(Semantic.primary) + .bulletColor(Semantic.secondary) + .markerColor(Semantic.secondary) + .gapWidth(12) + .marginLeft(24) + .marginBottom(16) } } } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/List.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/List.swift new file mode 100644 index 000000000..37c3b3c46 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/List.swift @@ -0,0 +1,100 @@ +import SwiftUI + +public struct List: MarkdownThemeElement { + public var fontSpec: ThemeFontSpec? + public var fontWeight: Font.Weight? + public var fontDesign: Font.Design? + public var foregroundColorSpec: ThemeColorSpec? + public var bulletColorSpec: ThemeColorSpec? + public var markerColorSpec: ThemeColorSpec? + public var marginTop: CGFloat? + public var marginBottom: CGFloat? + public var lineHeight: CGFloat? + public var textAlignment: TextAlignment? + public var marginLeft: CGFloat? + public var gapWidth: CGFloat? + public var bulletSize: CGFloat? + public var markerMinWidth: CGFloat? + + public init() {} + + public func bulletColor(_ color: Color) -> Self { + var copy = self + copy.bulletColorSpec = ThemeColorModifiers.spec(from: color) + return copy + } + + public func bulletColor(_ semantic: ThemeColorSpec.SemanticColor) -> Self { + var copy = self + copy.bulletColorSpec = ThemeColorModifiers.spec(from: semantic) + return copy + } + + public func markerColor(_ color: Color) -> Self { + var copy = self + copy.markerColorSpec = ThemeColorModifiers.spec(from: color) + return copy + } + + public func markerColor(_ semantic: ThemeColorSpec.SemanticColor) -> Self { + var copy = self + copy.markerColorSpec = ThemeColorModifiers.spec(from: semantic) + return copy + } + + public func marginLeft(_ value: CGFloat) -> Self { + var copy = self + copy.marginLeft = value + return copy + } + + public func gapWidth(_ value: CGFloat) -> Self { + var copy = self + copy.gapWidth = value + return copy + } + + public func bulletSize(_ value: CGFloat) -> Self { + var copy = self + copy.bulletSize = value + return copy + } + + public func markerMinWidth(_ value: CGFloat) -> Self { + var copy = self + copy.markerMinWidth = value + return copy + } + + public func apply(to config: inout MarkdownStyleConfig, traitCollection: UITraitCollection) { + applyElementStyle(to: &config.list, traitCollection: traitCollection) + if let bulletColorSpec { + config.list.bulletColor = bulletColorSpec.resolve(traitCollection: traitCollection) + } + if let markerColorSpec { + config.list.markerColor = markerColorSpec.resolve(traitCollection: traitCollection) + } + if let marginLeft { config.list.marginLeft = marginLeft } + if let gapWidth { config.list.gapWidth = gapWidth } + if let bulletSize { config.list.bulletSize = bulletSize } + if let markerMinWidth { config.list.markerMinWidth = markerMinWidth } + } + + private func applyElementStyle(to style: inout ListStyle, traitCollection: UITraitCollection) { + if fontSpec != nil || fontWeight != nil || fontDesign != nil { + style.font = ThemeResolver.applyFont( + spec: fontSpec, + weight: fontWeight, + design: fontDesign, + to: style.font, + traitCollection: traitCollection + ) + } + if let foregroundColorSpec { + style.foregroundColor = foregroundColorSpec.resolve(traitCollection: traitCollection) + } + if let marginTop { style.marginTop = marginTop } + if let marginBottom { style.marginBottom = marginBottom } + if let lineHeight { style.lineHeight = lineHeight } + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift index 8e76b64cf..dfe5d7844 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift @@ -202,6 +202,60 @@ public struct BlockquoteStyle: Equatable, Sendable { } } +public struct ListStyle: Equatable, Sendable { + public var font: UIFont? + public var foregroundColor: UIColor? + public var marginTop: CGFloat? + public var marginBottom: CGFloat? + public var lineHeight: CGFloat? + public var marginLeft: CGFloat? + public var gapWidth: CGFloat? + public var bulletColor: UIColor? + public var bulletSize: CGFloat? + public var markerMinWidth: CGFloat? + public var markerColor: UIColor? + + public init( + font: UIFont? = nil, + foregroundColor: UIColor? = nil, + marginTop: CGFloat? = nil, + marginBottom: CGFloat? = nil, + lineHeight: CGFloat? = nil, + marginLeft: CGFloat? = nil, + gapWidth: CGFloat? = nil, + bulletColor: UIColor? = nil, + bulletSize: CGFloat? = nil, + markerMinWidth: CGFloat? = nil, + markerColor: UIColor? = nil + ) { + self.font = font + self.foregroundColor = foregroundColor + self.marginTop = marginTop + self.marginBottom = marginBottom + self.lineHeight = lineHeight + self.marginLeft = marginLeft + self.gapWidth = gapWidth + self.bulletColor = bulletColor + self.bulletSize = bulletSize + self.markerMinWidth = markerMinWidth + self.markerColor = markerColor + } + + public mutating func merge(_ other: ListStyle) { + if let font = other.font { self.font = font } + if let foregroundColor = other.foregroundColor { self.foregroundColor = foregroundColor } + if let marginTop = other.marginTop { self.marginTop = marginTop } + if let marginBottom = other.marginBottom { self.marginBottom = marginBottom } + if let lineHeight = other.lineHeight { self.lineHeight = lineHeight } + if let marginLeft = other.marginLeft { self.marginLeft = marginLeft } + if let gapWidth = other.gapWidth { self.gapWidth = gapWidth } + if let bulletColor = other.bulletColor { self.bulletColor = bulletColor } + if let bulletSize = other.bulletSize { self.bulletSize = bulletSize } + if let markerMinWidth = other.markerMinWidth { self.markerMinWidth = markerMinWidth } + if let markerColor = other.markerColor { self.markerColor = markerColor } + } +} + public struct MarkdownStyleConfig: Equatable, Sendable { public var paragraph: ElementStyle public var heading1: ElementStyle @@ -219,6 +273,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable { public var thematicBreak: ThematicBreakStyle public var codeBlock: CodeBlockStyle public var blockquote: BlockquoteStyle + public var list: ListStyle public init( paragraph: ElementStyle = ElementStyle(), @@ -236,7 +291,8 @@ public struct MarkdownStyleConfig: Equatable, Sendable { inlineImage: InlineImageStyle = InlineImageStyle(), thematicBreak: ThematicBreakStyle = ThematicBreakStyle(), codeBlock: CodeBlockStyle = CodeBlockStyle(), - blockquote: BlockquoteStyle = BlockquoteStyle() + blockquote: BlockquoteStyle = BlockquoteStyle(), + list: ListStyle = ListStyle() ) { self.paragraph = paragraph self.heading1 = heading1 @@ -254,6 +310,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable { self.thematicBreak = thematicBreak self.codeBlock = codeBlock self.blockquote = blockquote + self.list = list } public mutating func merge(_ other: MarkdownStyleConfig) { @@ -273,6 +330,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable { thematicBreak.merge(other.thematicBreak) codeBlock.merge(other.codeBlock) blockquote.merge(other.blockquote) + list.merge(other.list) } public func headingStyle(for level: Int) -> ElementStyle { diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/BlockDecorationConfig.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/BlockDecorationConfig.swift index 86967700f..d445ca282 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/BlockDecorationConfig.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/BlockDecorationConfig.swift @@ -12,9 +12,16 @@ struct BlockDecorationConfig { var blockquoteBorderColor: UIColor = UIColor(red: 0.82, green: 0.84, blue: 0.86, alpha: 1) var blockquoteBackgroundColor: UIColor = UIColor(red: 0.98, green: 0.98, blue: 0.99, alpha: 1) + var listGapWidth: CGFloat = 12 + var listBulletSize: CGFloat = 6 + var listBulletColor: UIColor = UIColor(red: 0.42, green: 0.45, blue: 0.50, alpha: 1) + var listMarkerColor: UIColor = UIColor(red: 0.42, green: 0.45, blue: 0.50, alpha: 1) + var listMarkerFont: UIFont = .systemFont(ofSize: 16, weight: .medium) + init(styleConfig: MarkdownStyleConfig) { applyCodeBlockStyle(from: styleConfig.codeBlock) applyBlockquoteStyle(from: styleConfig.blockquote) + applyListStyle(from: styleConfig.list) } private mutating func applyCodeBlockStyle(from style: CodeBlockStyle) { @@ -49,4 +56,22 @@ struct BlockDecorationConfig { blockquoteBackgroundColor = color } } + + private mutating func applyListStyle(from style: ListStyle) { + if let gap = style.gapWidth { + listGapWidth = max(gap, 4) + } + if let size = style.bulletSize { + listBulletSize = size + } + if let color = style.bulletColor { + listBulletColor = color + } + if let color = style.markerColor { + listMarkerColor = color + } + if let font = style.font { + listMarkerFont = font + } + } } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/ListMarkerDrawer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/ListMarkerDrawer.swift new file mode 100644 index 000000000..1ae6fa0cd --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/ListMarkerDrawer.swift @@ -0,0 +1,187 @@ +import CoreText +import UIKit + +enum ListMarkerDrawer { + static func draw(in drawContext: ListDrawContext) { + let visibleCharacterRange = drawContext.visibleCharacterRange + guard visibleCharacterRange.length > 0 else { return } + + let config = drawContext.decorationConfig + let gap = config.listGapWidth + let string = drawContext.textStorage.string as NSString + var drawnParagraphs = Set() + var location = visibleCharacterRange.location + let end = NSMaxRange(visibleCharacterRange) + + while location < end { + let paragraphRange = string.paragraphRange(for: NSRange(location: location, length: 0)) + defer { location = NSMaxRange(paragraphRange) } + + guard paragraphRange.length > 0, + paragraphRange.location < drawContext.textStorage.length, + !drawnParagraphs.contains(paragraphRange.location) else { + continue + } + drawnParagraphs.insert(paragraphRange.location) + + let attrs = drawContext.textStorage.attributes(at: paragraphRange.location, effectiveRange: nil) + guard MarkdownAttributeValue.intValue(from: attrs[MarkdownAttribute.listDepth]) != nil else { + continue + } + + let isRTL = paragraphIsRTL(attrs[.paragraphStyle] as? NSParagraphStyle) + let layoutInfo = layoutInfo( + ParagraphLayoutRequest( + paragraphRange: paragraphRange, + textLayoutManager: drawContext.textLayoutManager, + contentManager: drawContext.contentManager, + attrs: attrs, + gap: gap, + origin: drawContext.origin, + isRTL: isRTL + ) + ) + + if MarkdownAttributeValue.intValue(from: attrs[MarkdownAttribute.listType]) == ListType.unordered.rawValue { + let depth = MarkdownAttributeValue.intValue(from: attrs[MarkdownAttribute.listDepth]) ?? 0 + let font = (attrs[.font] as? UIFont) ?? UIFont.systemFont(ofSize: 16) + let bulletY = bulletCenterY(visualBaselineY: layoutInfo.visualBaselineY, font: font) + drawBullet(at: CGPoint(x: layoutInfo.markerX, y: bulletY), depth: depth, config: config, in: drawContext.context) + } else if let number = MarkdownAttributeValue.intValue(from: attrs[MarkdownAttribute.listItemNumber]) { + drawOrderedMarker( + at: layoutInfo.markerX, + number: number, + baselineY: layoutInfo.visualBaselineY, + isRTL: isRTL, + config: config, + in: drawContext.context + ) + } + } + } + + private struct ParagraphLayoutInfo { + let markerX: CGFloat + let visualBaselineY: CGFloat + } + + private struct ParagraphLayoutRequest { + let paragraphRange: NSRange + let textLayoutManager: NSTextLayoutManager + let contentManager: NSTextContentManager + let attrs: [NSAttributedString.Key: Any] + let gap: CGFloat + let origin: CGPoint + let isRTL: Bool + } + + private static func layoutInfo(_ request: ParagraphLayoutRequest) -> ParagraphLayoutInfo { + let paragraphStyle = request.attrs[.paragraphStyle] as? NSParagraphStyle + let textStartX = paragraphStyle?.headIndent ?? paragraphStyle?.firstLineHeadIndent ?? 0 + let font = (request.attrs[.font] as? UIFont) ?? UIFont.systemFont(ofSize: 16) + var segmentFrame = CGRect(x: textStartX, y: 0, width: 0, height: 0) + var baselineFromLineTop = font.ascender + + if let textRange = TextLayoutHelpers.textRange(request.paragraphRange, in: request.contentManager) { + request.textLayoutManager.enumerateTextSegments( + in: textRange, + type: .standard, + options: [] + ) { _, frame, baseline, _ in + segmentFrame = frame + baselineFromLineTop = baseline + return false + } + } + + let layoutBaselineY = request.origin.y + segmentFrame.minY + baselineFromLineTop + let baselineOffset = CGFloat((request.attrs[.baselineOffset] as? NSNumber)?.doubleValue ?? 0) + let visualBaselineY = layoutBaselineY - baselineOffset + + let markerX: CGFloat + if request.isRTL { + let textEndX = max(segmentFrame.maxX, textStartX) + markerX = request.origin.x + textEndX + request.gap + } else { + let textOriginX = segmentFrame.width > 0 ? segmentFrame.minX : textStartX + markerX = request.origin.x + textOriginX - request.gap + } + + return ParagraphLayoutInfo(markerX: markerX, visualBaselineY: visualBaselineY) + } + + private static func bulletCenterY(visualBaselineY: CGFloat, font: UIFont) -> CGFloat { + visualBaselineY - typographicXHeight(for: font) / 2 + } + + private static func typographicXHeight(for font: UIFont) -> CGFloat { + let xHeight = CTFontGetXHeight(font as CTFont) + return xHeight > 0 ? xHeight : font.capHeight * 0.7 + } + + private static func drawBullet( + at point: CGPoint, + depth: Int, + config: BlockDecorationConfig, + in context: CGContext + ) { + let size = config.listBulletSize + let rect = CGRect( + x: point.x - size / 2, + y: point.y - size / 2, + width: size, + height: size + ) + + context.saveGState() + switch depth { + case 0: + context.setFillColor(config.listBulletColor.cgColor) + context.fillEllipse(in: rect) + case 1: + let lineWidth = max(1, size * 0.15) + context.setStrokeColor(config.listBulletColor.cgColor) + context.setLineWidth(lineWidth) + context.strokeEllipse(in: rect.insetBy(dx: lineWidth / 2, dy: lineWidth / 2)) + default: + context.setFillColor(config.listBulletColor.cgColor) + context.fill(rect) + } + context.restoreGState() + } + + private static func drawOrderedMarker( + at boundaryX: CGFloat, + number: Int, + baselineY: CGFloat, + isRTL: Bool, + config: BlockDecorationConfig, + in context: CGContext + ) { + let text = isRTL ? ".\(number)" : "\(number)." + let attributes: [NSAttributedString.Key: Any] = [ + .font: config.listMarkerFont, + .foregroundColor: config.listMarkerColor + ] + let size = (text as NSString).size(withAttributes: attributes) + let drawX = isRTL ? boundaryX : boundaryX - size.width + (text as NSString).draw( + at: CGPoint(x: drawX, y: baselineY - config.listMarkerFont.ascender), + withAttributes: attributes + ) + } + + private static func paragraphIsRTL(_ style: NSParagraphStyle?) -> Bool { + guard let style else { + return UIView.userInterfaceLayoutDirection( + for: UIView.appearance().semanticContentAttribute + ) == .rightToLeft + } + if style.baseWritingDirection != .natural { + return style.baseWritingDirection == .rightToLeft + } + return UIView.userInterfaceLayoutDirection( + for: UIView.appearance().semanticContentAttribute + ) == .rightToLeft + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/MarkdownViewportDecorator.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/MarkdownViewportDecorator.swift index ba555ca65..b09b315ba 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/MarkdownViewportDecorator.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/MarkdownViewportDecorator.swift @@ -40,23 +40,44 @@ final class MarkdownViewportDecorator { textLayoutManager: textLayoutManager, contentManager: contentManager ) - let drawContext = BlockDrawContext( - context: context, - textStorage: textStorage, - textLayoutManager: textLayoutManager, - contentManager: contentManager, - containerWidth: containerWidth, - origin: origin, - visibleCharacterRange: visibleRange, - decorationConfig: config - ) switch pass { case .background: + let drawContext = BlockDrawContext( + context: context, + textStorage: textStorage, + textLayoutManager: textLayoutManager, + contentManager: contentManager, + containerWidth: containerWidth, + origin: origin, + visibleCharacterRange: visibleRange, + decorationConfig: config + ) CodeBlockBackgroundDrawer.draw(in: drawContext) BlockquoteBorderDrawer.drawBackgrounds(in: drawContext) case .foreground: + let drawContext = BlockDrawContext( + context: context, + textStorage: textStorage, + textLayoutManager: textLayoutManager, + contentManager: contentManager, + containerWidth: containerWidth, + origin: origin, + visibleCharacterRange: visibleRange, + decorationConfig: config + ) BlockquoteBorderDrawer.drawBorders(in: drawContext) + ListMarkerDrawer.draw( + in: ListDrawContext( + context: context, + textStorage: textStorage, + textLayoutManager: textLayoutManager, + contentManager: contentManager, + origin: origin, + visibleCharacterRange: visibleRange, + decorationConfig: config + ) + ) } } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/TextLayoutHelpers.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/TextLayoutHelpers.swift index e5f7f7eb5..bed93d25d 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/TextLayoutHelpers.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Layout/TextLayoutHelpers.swift @@ -11,6 +11,16 @@ struct BlockDrawContext { let decorationConfig: BlockDecorationConfig } +struct ListDrawContext { + let context: CGContext + let textStorage: NSTextStorage + let textLayoutManager: NSTextLayoutManager + let contentManager: NSTextContentManager + let origin: CGPoint + let visibleCharacterRange: NSRange + let decorationConfig: BlockDecorationConfig +} + enum TextLayoutHelpers { static func nsRange(_ textRange: NSTextRange, in contentManager: NSTextContentManager) -> NSRange? { let start = contentManager.offset( diff --git a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift index 4f59976cc..cf79685cd 100644 --- a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift +++ b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift @@ -608,6 +608,95 @@ final class RendererTests: XCTestCase { } XCTAssertTrue(foundDepth) } + + + func testUnorderedListItemsHaveListAttributes() { + let result = MarkdownRenderer.render("- first\n- second", config: config) + + var itemCount = 0 + result.enumerateAttribute(MarkdownAttribute.listItemNumber, in: NSRange(location: 0, length: result.length)) { value, _, _ in + if value != nil { itemCount += 1 } + } + XCTAssertGreaterThanOrEqual(itemCount, 2) + } + + + func testOrderedListItemsHaveSequentialNumbers() { + let result = MarkdownRenderer.render("1. first\n2. second", config: config) + + var numbers = Set() + result.enumerateAttribute(MarkdownAttribute.listItemNumber, in: NSRange(location: 0, length: result.length)) { value, _, _ in + if let number = value as? Int { + numbers.insert(number) + } + } + XCTAssertTrue(numbers.contains(1)) + XCTAssertTrue(numbers.contains(2)) + } + + + func testListFirstItemAfterLabelHasListAttributes() { + let result = MarkdownRenderer.render("Unordered:\n\n- Alpha\n- Beta", config: config) + + guard let alphaRange = (result.string as NSString).range(of: "Alpha") as NSRange?, + alphaRange.location != NSNotFound else { + XCTFail("Missing Alpha list item") + return + } + + let depth = MarkdownAttributeValue.intValue( + from: result.attribute(MarkdownAttribute.listDepth, at: alphaRange.location, effectiveRange: nil) + ) + let number = MarkdownAttributeValue.intValue( + from: result.attribute(MarkdownAttribute.listItemNumber, at: alphaRange.location, effectiveRange: nil) + ) + XCTAssertEqual(depth, 0) + XCTAssertEqual(number, 1) + + let labelRange = (result.string as NSString).range(of: "Unordered:") + let labelParagraph = (result.string as NSString).paragraphRange(for: labelRange) + let alphaParagraph = (result.string as NSString).paragraphRange(for: alphaRange) + XCTAssertNotEqual(labelParagraph.location, alphaParagraph.location) + } + + + func testListItemWithBoldPreservesFormatting() { + let result = MarkdownRenderer.render("- **bold** item", config: config) + + var foundBold = false + result.enumerateAttribute(.font, in: NSRange(location: 0, length: result.length)) { value, range, _ in + guard let font = value as? UIFont else { return } + if result.string[range].contains("bold") { + XCTAssertTrue(font.fontDescriptor.symbolicTraits.contains(.traitBold)) + foundBold = true + } + } + XCTAssertTrue(foundBold) + } + + + func testListFollowedByCodeBlockKeepsExternalMarginSpacer() { + let markdown = """ + - Satellite monitoring + + ```python + def detect(): + pass + ``` + """ + let result = MarkdownRenderer.render(markdown, config: config) + + let monitoringRange = (result.string as NSString).range(of: "Satellite monitoring") + XCTAssertNotEqual(monitoringRange.location, NSNotFound) + + var codeBlockLocation = NSNotFound + result.enumerateAttribute(MarkdownAttribute.codeBlock, in: NSRange(location: 0, length: result.length)) { value, range, _ in + guard (value as? Bool) == true else { return } + codeBlockLocation = range.location + } + XCTAssertNotEqual(codeBlockLocation, NSNotFound) + XCTAssertGreaterThan(codeBlockLocation, NSMaxRange(monitoringRange)) + } } private extension String {