diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/AttributedRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/AttributedRenderer.swift new file mode 100644 index 00000000..71f61046 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/AttributedRenderer.swift @@ -0,0 +1,27 @@ +import UIKit + +final class AttributedRenderer { + private let config: MarkdownStyleConfig + private let factory: RendererFactory + + init(config: MarkdownStyleConfig) { + self.config = config + self.factory = RendererFactory(config: config) + } + + func renderRoot(_ root: MarkdownASTNode) -> NSMutableAttributedString { + let context = RenderContext() + let output = NSMutableAttributedString() + + let paragraphFont = config.paragraph.font ?? UIFont.preferredFont(forTextStyle: .body) + let paragraphColor = config.paragraph.foregroundColor ?? UIColor.label + context.setBlockStyle(font: paragraphFont, color: paragraphColor) + + for child in root.children { + factory.renderer(for: child.type).render(node: child, into: output, context: context) + } + + context.clearBlockStyle() + return output + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/FontHelpers.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/FontHelpers.swift new file mode 100644 index 00000000..d9285b49 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/FontHelpers.swift @@ -0,0 +1,29 @@ +import UIKit + +enum FontHelpers { + static func ensureBold(_ font: UIFont?) -> UIFont? { + guard let font else { return nil } + let traits = font.fontDescriptor.symbolicTraits + guard !traits.contains(.traitBold) else { return font } + + guard let descriptor = font.fontDescriptor.withSymbolicTraits(traits.union(.traitBold)) else { + return font + } + return UIFont(descriptor: descriptor, size: 0) + } + + static func ensureItalic(_ font: UIFont?) -> UIFont? { + guard let font else { return nil } + let traits = font.fontDescriptor.symbolicTraits + guard !traits.contains(.traitItalic) else { return font } + + guard let descriptor = font.fontDescriptor.withSymbolicTraits(traits.union(.traitItalic)) else { + return font + } + return UIFont(descriptor: descriptor, size: 0) + } + + static func cachedFont(from blockStyle: BlockStyle?) -> UIFont? { + blockStyle?.font + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/MarkdownRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/MarkdownRenderer.swift new file mode 100644 index 00000000..4d7bc26f --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/MarkdownRenderer.swift @@ -0,0 +1,13 @@ +import UIKit + +public enum MarkdownRenderer { + public static func render( + _ markdown: String, + config: MarkdownStyleConfig, + flags: Md4cFlags = .commonMark + ) -> NSAttributedString { + let ast = Parser.shared.parseMarkdown(markdown, flags: flags) + let renderer = AttributedRenderer(config: config) + return renderer.renderRoot(ast) + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/NodeRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/NodeRenderer.swift new file mode 100644 index 00000000..c3609300 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/NodeRenderer.swift @@ -0,0 +1,17 @@ +import UIKit + +protocol NodeRenderer: AnyObject { + func render(node: MarkdownASTNode, into output: NSMutableAttributedString, context: RenderContext) +} + +final class ChildrenOnlyRenderer: NodeRenderer { + private let factory: RendererFactory + + init(factory: RendererFactory) { + self.factory = factory + } + + func render(node: MarkdownASTNode, into output: NSMutableAttributedString, context: RenderContext) { + factory.renderChildren(of: node, into: output, context: context) + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/ParagraphStyleHelpers.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/ParagraphStyleHelpers.swift new file mode 100644 index 00000000..aa0a4ff6 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/ParagraphStyleHelpers.swift @@ -0,0 +1,242 @@ +import CoreText +import UIKit + +enum ParagraphStyleHelpers { + static let newline = NSAttributedString(string: "\n") + + @discardableResult + static func applyParagraphSpacingBefore( + to output: NSMutableAttributedString, + range: NSRange, + marginTop: CGFloat + ) -> Int { + guard range.location > 0, marginTop > 0 else { return 0 } + + let style = NSMutableParagraphStyle() + style.paragraphSpacingBefore = marginTop + + let spacer = NSMutableAttributedString(attributedString: newline) + spacer.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: 1)) + output.insert(spacer, at: range.location) + return 1 + } + + @discardableResult + static func applyBlockSpacingBefore( + to output: NSMutableAttributedString, + at insertionPoint: Int, + marginTop: CGFloat + ) -> Int { + guard marginTop > 0 else { return 0 } + + let style = NSMutableParagraphStyle() + style.paragraphSpacingBefore = marginTop + + let spacer = NSMutableAttributedString(attributedString: newline) + spacer.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: 1)) + output.insert(spacer, at: insertionPoint) + return 1 + } + + /// Appends a trailing newline and sets `paragraphSpacing` on the block paragraph. + /// UITextView lays out `paragraphSpacing` reliably; a dedicated spacer line with only + /// `minimumLineHeight`/`maximumLineHeight` is often collapsed to zero height. + static func applyParagraphSpacingAfter( + to output: NSMutableAttributedString, + at contentStart: Int, + marginBottom: CGFloat + ) { + guard contentStart <= output.length else { return } + + output.append(newline) + + let style = getOrCreateParagraphStyle(in: output, at: contentStart) + style.paragraphSpacing = marginBottom + + let range = NSRange(location: contentStart, length: output.length - contentStart) + output.addAttribute(.paragraphStyle, value: style, range: range) + } + + static func applyLineHeight( + to output: NSMutableAttributedString, + range: NSRange, + lineHeight: CGFloat + ) { + guard lineHeight > 0, range.length > 0 else { return } + + let roundedLineHeight = ceil(lineHeight) + let style = getOrCreateParagraphStyle(in: output, at: range.location) + style.lineSpacing = 0 + style.minimumLineHeight = roundedLineHeight + style.maximumLineHeight = roundedLineHeight + output.addAttribute(.paragraphStyle, value: style, range: range) + } + + static func applyBaselineOffset( + to output: NSMutableAttributedString, + range: NSRange + ) { + guard range.length > 0 else { return } + + var maximumLineHeight: CGFloat = 0 + output.enumerateAttribute(.paragraphStyle, in: range) { value, _, _ in + guard let paragraphStyle = value as? NSParagraphStyle else { return } + maximumLineHeight = max(paragraphStyle.maximumLineHeight, maximumLineHeight) + } + guard maximumLineHeight > 0 else { return } + + output.enumerateAttribute(.font, in: range) { value, subrange, _ in + guard let font = value as? UIFont else { return } + guard output.attribute(.baselineOffset, at: subrange.location, effectiveRange: nil) == nil else { + return + } + + let naturalHeight = typographicLineHeight(for: font) + guard naturalHeight > 0, maximumLineHeight >= naturalHeight else { return } + + let leading = maximumLineHeight - naturalHeight + let baselineOffset = ceil(leading / 2) + output.addAttribute(.baselineOffset, value: baselineOffset, range: subrange) + } + } + + static func applyBlockLineHeight( + to output: NSMutableAttributedString, + range: NSRange, + lineHeight: CGFloat + ) { + applyLineHeight(to: output, range: range, lineHeight: lineHeight) + applyBaselineOffset(to: output, range: range) + } + + static func applyTextAlignment( + to output: NSMutableAttributedString, + range: NSRange, + alignment: NSTextAlignment + ) { + guard range.length > 0 else { return } + + output.enumerateAttribute( + .paragraphStyle, + in: range, + options: [] + ) { value, subrange, _ in + let paragraphStyle: NSMutableParagraphStyle + if let existing = value as? NSParagraphStyle, + let mutable = existing.mutableCopy() as? NSMutableParagraphStyle { + paragraphStyle = mutable + } else { + paragraphStyle = NSMutableParagraphStyle() + } + paragraphStyle.alignment = alignment + output.addAttribute(.paragraphStyle, value: paragraphStyle, range: subrange) + } + } + + static func getOrCreateParagraphStyle( + in output: NSMutableAttributedString, + at index: Int + ) -> NSMutableParagraphStyle { + if let existing = output.attribute(.paragraphStyle, at: index, effectiveRange: nil) as? NSParagraphStyle, + let mutable = existing.mutableCopy() as? NSMutableParagraphStyle { + return mutable + } + return NSMutableParagraphStyle() + } + + static func applyHeadIndent( + to output: NSMutableAttributedString, + range: NSRange, + indent: CGFloat + ) { + guard range.length > 0 else { return } + + output.enumerateAttribute( + .paragraphStyle, + in: range, + options: [] + ) { value, subrange, _ in + let paragraphStyle: NSMutableParagraphStyle + if let existing = value as? NSParagraphStyle, + let mutable = existing.mutableCopy() as? NSMutableParagraphStyle { + paragraphStyle = mutable + } else { + paragraphStyle = NSMutableParagraphStyle() + } + paragraphStyle.firstLineHeadIndent = indent + paragraphStyle.headIndent = indent + output.addAttribute(.paragraphStyle, value: paragraphStyle, range: subrange) + } + } + + static func applyTextLists( + to output: NSMutableAttributedString, + range: NSRange, + lists: [NSTextList] + ) { + guard range.length > 0 else { return } + + output.enumerateAttribute( + .paragraphStyle, + in: range, + options: [] + ) { value, subrange, _ in + let paragraphStyle: NSMutableParagraphStyle + if let existing = value as? NSParagraphStyle, + let mutable = existing.mutableCopy() as? NSMutableParagraphStyle { + paragraphStyle = mutable + } else { + paragraphStyle = NSMutableParagraphStyle() + } + paragraphStyle.textLists = lists + output.addAttribute(.paragraphStyle, value: paragraphStyle, range: subrange) + } + } + + static func applyTrailingVerticalPadding( + to output: NSMutableAttributedString, + range: NSRange, + padding: CGFloat + ) { + guard padding > 0, range.length > 0 else { return } + + let string = output.string as NSString + let lastParagraphLocation = max(range.location, NSMaxRange(range) - 1) + let lastParagraph = string.paragraphRange(for: NSRange(location: lastParagraphLocation, length: 0)) + let style = getOrCreateParagraphStyle(in: output, at: lastParagraph.location) + style.paragraphSpacing = padding + output.addAttribute(.paragraphStyle, value: style, range: lastParagraph) + } + + static func ensureTrailingNewline(in output: NSMutableAttributedString) { + guard output.length > 0, !output.string.hasSuffix("\n") else { return } + output.append(newline) + } + + static func ensureStartingOnNewLine(in output: NSMutableAttributedString) { + guard output.length > 0, !output.string.hasSuffix("\n") else { return } + output.append(newline) + } + + static func applyBlockSpacingAfter( + to output: NSMutableAttributedString, + marginBottom: CGFloat + ) { + guard marginBottom > 0, output.length > 0 else { return } + + let style = NSMutableParagraphStyle() + style.minimumLineHeight = 1 + style.maximumLineHeight = 1 + style.paragraphSpacing = marginBottom + + let spacer = NSMutableAttributedString(attributedString: newline) + spacer.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: 1)) + output.append(spacer) + } + + /// Matches Android `LineHeightSpan` natural height: `(-ascent) + descent`. + private static func typographicLineHeight(for font: UIFont) -> CGFloat { + let ctFont = font as CTFont + return CTFontGetAscent(ctFont) + CTFontGetDescent(ctFont) + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift new file mode 100644 index 00000000..e3993d50 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift @@ -0,0 +1,66 @@ +import UIKit + +enum BlockType { + case none + case paragraph +} + +struct BlockStyle { + var font: UIFont + var color: UIColor +} + +enum MarkdownAttribute { + static let inlineCode = NSAttributedString.Key("EnrichedMarkdownInlineCode") +} + +final class RenderContext { + 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 clearBlockStyle() { + currentBlockType = .none + currentBlockStyle = nil + } + + func getBlockStyle() -> BlockStyle? { + currentBlockStyle + } + + func getTextAttributes() -> [NSAttributedString.Key: Any] { + guard let blockStyle = currentBlockStyle else { + return [:] + } + return [ + .font: blockStyle.font, + .foregroundColor: blockStyle.color + ] + } + + static func shouldPreserveColors(_ attributes: [NSAttributedString.Key: Any]) -> Bool { + attributes[.link] != nil || attributes[MarkdownAttribute.inlineCode] != nil + } + + static func rangeForRenderedContent(in output: NSMutableAttributedString, start: Int) -> NSRange { + let length = output.length - start + guard length > 0 else { return NSRange(location: start, length: 0) } + return NSRange(location: start, length: length) + } + + static func calculateStrongColor(configColor: UIColor?, blockColor: UIColor) -> UIColor? { + guard let configColor, !configColor.isEqual(blockColor) else { + return nil + } + return configColor + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift new file mode 100644 index 00000000..bf826512 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift @@ -0,0 +1,46 @@ +import UIKit + +final class RendererFactory { + private let config: MarkdownStyleConfig + private var cache: [NodeType: NodeRenderer] = [:] + private lazy var childrenOnlyRenderer = ChildrenOnlyRenderer(factory: self) + + init(config: MarkdownStyleConfig) { + self.config = config + } + + func renderer(for type: NodeType) -> NodeRenderer { + if let cached = cache[type] { + return cached + } + + let renderer = createRenderer(for: type) + cache[type] = renderer + return renderer + } + + func renderChildren( + of node: MarkdownASTNode, + into output: NSMutableAttributedString, + context: RenderContext + ) { + for child in node.children { + renderer(for: child.type).render(node: child, into: output, context: context) + } + } + + private func createRenderer(for type: NodeType) -> NodeRenderer { + switch type { + case .text: + return TextRenderer() + case .paragraph: + return ParagraphRenderer(factory: self, config: config) + case .strong: + return StrongRenderer(factory: self, config: config) + case .emphasis: + return EmphasisRenderer(factory: self, config: config) + default: + return childrenOnlyRenderer + } + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/EmphasisRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/EmphasisRenderer.swift new file mode 100644 index 00000000..4ceb990c --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/EmphasisRenderer.swift @@ -0,0 +1,48 @@ +import UIKit + +final class EmphasisRenderer: 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 start = output.length + factory.renderChildren(of: node, into: output, context: context) + + let range = RenderContext.rangeForRenderedContent(in: output, start: start) + guard range.length > 0 else { return } + + let blockStyle = context.getBlockStyle() + let blockColor = blockStyle?.color ?? UIColor.label + let emphasisColor = config.emphasis.foregroundColor + let strongColorToPreserve = RenderContext.calculateStrongColor( + configColor: config.strong.foregroundColor, + blockColor: blockColor + ) + + output.enumerateAttributes(in: range, options: []) { attributes, subrange, _ in + let currentFont = (attributes[.font] as? UIFont) ?? FontHelpers.cachedFont(from: blockStyle) + let resolvedFont = FontHelpers.ensureItalic(currentFont) ?? currentFont + + if let currentFont, resolvedFont != currentFont { + output.addAttribute(.font, value: resolvedFont, range: subrange) + } + + if let emphasisColor { + let currentColor = attributes[.foregroundColor] as? UIColor + let isLink = attributes[.link] != nil + let isStrongColor = strongColorToPreserve.map { currentColor?.isEqual($0) == true } ?? false + + if !isLink, !isStrongColor, !RenderContext.shouldPreserveColors(attributes) { + if currentColor != emphasisColor { + output.addAttribute(.foregroundColor, value: emphasisColor, range: subrange) + } + } + } + } + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/ParagraphRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/ParagraphRenderer.swift new file mode 100644 index 00000000..8fcf245e --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/ParagraphRenderer.swift @@ -0,0 +1,83 @@ +import UIKit + +final class ParagraphRenderer: 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 isTopLevel = context.currentBlockType == .none + let paragraphStyle = config.paragraph + + if isTopLevel { + let font = paragraphStyle.font ?? UIFont.preferredFont(forTextStyle: .body) + let color = paragraphStyle.foregroundColor ?? UIColor.label + context.setBlockStyle(font: font, color: color) + } + + let start = output.length + let shouldApplyMargin = context.currentBlockType == .none || context.currentBlockType == .paragraph + let isBlockImage = node.children.count == 1 && node.children[0].type == .image + let imageStyle = config.image + let marginTop = isBlockImage + ? (imageStyle.marginTop ?? paragraphStyle.marginTop ?? 0) + : (paragraphStyle.marginTop ?? 0) + var contentStart = start + + if shouldApplyMargin, start == 0, marginTop > 0 { + let offset = ParagraphStyleHelpers.applyBlockSpacingBefore( + to: output, + at: 0, + marginTop: marginTop + ) + contentStart += offset + } + + if isTopLevel { + factory.renderChildren(of: node, into: output, context: context) + context.clearBlockStyle() + } else { + factory.renderChildren(of: node, into: output, context: context) + if context.currentBlockType == .blockquote { + ParagraphStyleHelpers.ensureTrailingNewline(in: output) + } + } + + guard output.length > start else { return } + + let range = NSRange(location: start, length: output.length - start) + + if !isBlockImage, let lineHeight = paragraphStyle.lineHeight { + ParagraphStyleHelpers.applyBlockLineHeight(to: output, range: range, lineHeight: lineHeight) + } + + if let alignment = paragraphStyle.textAlignment { + ParagraphStyleHelpers.applyTextAlignment(to: output, range: range, alignment: alignment) + } + + var spacingStart = start + if shouldApplyMargin, contentStart != 0 { + spacingStart += ParagraphStyleHelpers.applyParagraphSpacingBefore( + to: output, + range: range, + marginTop: marginTop + ) + } + + let marginBottom = isBlockImage + ? (imageStyle.marginBottom ?? paragraphStyle.marginBottom) + : paragraphStyle.marginBottom + + if shouldApplyMargin, let marginBottom { + ParagraphStyleHelpers.applyParagraphSpacingAfter( + to: output, + at: spacingStart, + marginBottom: marginBottom + ) + } + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/StrongRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/StrongRenderer.swift new file mode 100644 index 00000000..59c75817 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/StrongRenderer.swift @@ -0,0 +1,42 @@ +import UIKit + +final class StrongRenderer: 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 start = output.length + factory.renderChildren(of: node, into: output, context: context) + + let range = RenderContext.rangeForRenderedContent(in: output, start: start) + guard range.length > 0 else { return } + + let blockStyle = context.getBlockStyle() + let blockColor = blockStyle?.color ?? UIColor.label + let strongColor = RenderContext.calculateStrongColor( + configColor: config.strong.foregroundColor, + blockColor: blockColor + ) + + output.enumerateAttributes(in: range, options: []) { attributes, subrange, _ in + let currentFont = (attributes[.font] as? UIFont) ?? FontHelpers.cachedFont(from: blockStyle) + let resolvedFont = FontHelpers.ensureBold(currentFont) ?? currentFont + + if let currentFont, resolvedFont != currentFont { + output.addAttribute(.font, value: resolvedFont, range: subrange) + } + + if let strongColor, !RenderContext.shouldPreserveColors(attributes) { + let currentColor = attributes[.foregroundColor] as? UIColor + if currentColor != strongColor { + output.addAttribute(.foregroundColor, value: strongColor, range: subrange) + } + } + } + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/TextRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/TextRenderer.swift new file mode 100644 index 00000000..60ee53f5 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/TextRenderer.swift @@ -0,0 +1,9 @@ +import UIKit + +final class TextRenderer: NodeRenderer { + func render(node: MarkdownASTNode, into output: NSMutableAttributedString, context: RenderContext) { + guard !node.content.isEmpty else { return } + let text = NSAttributedString(string: node.content, attributes: context.getTextAttributes()) + output.append(text) + } +} diff --git a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/ParagraphStyleHelpersTests.swift b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/ParagraphStyleHelpersTests.swift new file mode 100644 index 00000000..4fe46192 --- /dev/null +++ b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/ParagraphStyleHelpersTests.swift @@ -0,0 +1,101 @@ +import UIKit +import XCTest +@testable import EnrichedMarkdown + +final class ParagraphStyleHelpersTests: XCTestCase { + func testApplyHeadIndentSetsFirstLineAndHeadIndent() { + let output = NSMutableAttributedString(string: "Hello\nWorld") + let range = NSRange(location: 0, length: output.length) + + ParagraphStyleHelpers.applyHeadIndent(to: output, range: range, indent: 24) + + var effectiveRange = NSRange() + let style = output.attribute(.paragraphStyle, at: 0, effectiveRange: &effectiveRange) as? NSParagraphStyle + XCTAssertNotNil(style) + XCTAssertEqual(style?.firstLineHeadIndent, 24) + XCTAssertEqual(style?.headIndent, 24) + } + + func testApplyTextListsSetsTextListsOnParagraph() { + let output = NSMutableAttributedString(string: "Item\n") + let list = NSTextList(markerFormat: .disc, options: 0) + + ParagraphStyleHelpers.applyTextLists( + to: output, + range: NSRange(location: 0, length: output.length), + lists: [list] + ) + + let style = output.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle + XCTAssertEqual(style?.textLists.count, 1) + XCTAssertEqual(style?.textLists.first?.markerFormat, .disc) + } + + func testEnsureTrailingNewlineAppendsWhenMissing() { + let output = NSMutableAttributedString(string: "text") + ParagraphStyleHelpers.ensureTrailingNewline(in: output) + XCTAssertTrue(output.string.hasSuffix("\n")) + } + + func testEnsureTrailingNewlineDoesNotDuplicate() { + let output = NSMutableAttributedString(string: "text\n") + ParagraphStyleHelpers.ensureTrailingNewline(in: output) + XCTAssertEqual(output.string, "text\n") + } + + func testApplyBlockSpacingAfterAppendsSpacerNewline() { + let output = NSMutableAttributedString(string: "block") + ParagraphStyleHelpers.applyBlockSpacingAfter(to: output, marginBottom: 16) + + XCTAssertEqual(output.string, "block\n") + let style = output.attribute(.paragraphStyle, at: output.length - 1, effectiveRange: nil) as? NSParagraphStyle + XCTAssertEqual(style?.paragraphSpacing, 16) + } + + func testApplyParagraphSpacingAfterSetsParagraphSpacingOnContent() { + let output = NSMutableAttributedString(string: "Hello") + ParagraphStyleHelpers.applyParagraphSpacingAfter(to: output, at: 0, marginBottom: 16) + + XCTAssertEqual(output.string, "Hello\n") + let contentStyle = output.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle + XCTAssertEqual(contentStyle?.paragraphSpacing, 16) + } + + func testApplyParagraphSpacingAfterWithZeroMarginAppendsNewline() { + let output = NSMutableAttributedString(string: "Hello") + ParagraphStyleHelpers.applyParagraphSpacingAfter(to: output, at: 0, marginBottom: 0) + + XCTAssertEqual(output.string, "Hello\n") + let contentStyle = output.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle + XCTAssertEqual(contentStyle?.paragraphSpacing, 0) + } + + func testApplyBlockLineHeightSetsParagraphStyleAndBaselineOffset() { + let font = UIFont.systemFont(ofSize: 16, weight: .regular) + let output = NSMutableAttributedString( + string: "Hello", + attributes: [.font: font] + ) + + ParagraphStyleHelpers.applyBlockLineHeight( + to: output, + range: NSRange(location: 0, length: output.length), + lineHeight: 26 + ) + + let style = output.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle + XCTAssertEqual(style?.minimumLineHeight, 26) + XCTAssertEqual(style?.maximumLineHeight, 26) + + let baselineOffset = output.attribute(.baselineOffset, at: 0, effectiveRange: nil) as? CGFloat + XCTAssertNotNil(baselineOffset) + XCTAssertGreaterThan(baselineOffset ?? 0, 0) + } + + func testSpacerStyleSetsLineHeight() { + let context = RenderContext() + let style = context.spacerStyle(height: 16, spacing: 0) + XCTAssertEqual(style.minimumLineHeight, 16) + XCTAssertEqual(style.maximumLineHeight, 16) + } +}